diff --git a/src/MiniExcel.Core/Helpers/NetSatandardExtensions.cs b/src/MiniExcel.Core/Helpers/NetStandardHelper.cs similarity index 57% rename from src/MiniExcel.Core/Helpers/NetSatandardExtensions.cs rename to src/MiniExcel.Core/Helpers/NetStandardHelper.cs index 50d412a9..16372635 100644 --- a/src/MiniExcel.Core/Helpers/NetSatandardExtensions.cs +++ b/src/MiniExcel.Core/Helpers/NetStandardHelper.cs @@ -1,11 +1,16 @@ namespace MiniExcelLib.Core.Helpers; +#if NETSTANDARD2_0 + +/// +/// Provides .NET Standard 2.0 polyfills for utility methods found in later framework versions. +/// This enables a unified API surface across the codebase without the need for conditional compilation directives. +/// public static class NetStandardExtensions { -#if NETSTANDARD2_0 public static TValue? GetValueOrDefault(this IReadOnlyDictionary dictionary, TKey key, TValue? defaultValue = default) { return dictionary.TryGetValue(key, out var value) ? value : defaultValue; } -#endif } +#endif diff --git a/src/MiniExcel.Core/Helpers/SynchronousHelper.cs b/src/MiniExcel.Core/Helpers/SynchronousHelper.cs new file mode 100644 index 00000000..9277af6c --- /dev/null +++ b/src/MiniExcel.Core/Helpers/SynchronousHelper.cs @@ -0,0 +1,16 @@ +using System.IO.Compression; + +namespace MiniExcelLib.Core.Helpers; + +/// +/// Supplements base classes with synchronous method counterparts, ensuring compatibility with the SyncMethodGenerator +/// by providing missing entry points without requiring manual preprocessor directives (#if SYNC_ONLY) +/// +public static class SynchronousHelper +{ + extension(ZipArchive) + { + public static ZipArchive Create(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? encoding = null) + => new(stream, mode, leaveOpen, encoding); + } +} diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs index 535602a4..24e78a8b 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs @@ -223,11 +223,11 @@ public async Task> GetSheetNamesAsync(Stream stream, OpenXmlConfigu { config ??= OpenXmlConfiguration.Default; - using var archive = new OpenXmlZip(stream, leaveOpen: true); - + var archive = await OpenXmlZip.CreateAsync(stream, leaveOpen: true, cancellationToken: cancellationToken).ConfigureAwait(false); + await using var disposableArchive = archive.ConfigureAwait(false); using var reader = await OpenXmlReader.CreateAsync(stream, config, cancellationToken: cancellationToken).ConfigureAwait(false); - var rels = await reader.GetWorkbookRelsAsync(archive.EntryCollection, cancellationToken).ConfigureAwait(false); + var rels = await reader.GetWorkbookRelsAsync(archive.EntryCollection, cancellationToken).ConfigureAwait(false); return rels?.Select(s => s.Name).ToList() ?? []; } @@ -248,10 +248,11 @@ public async Task> GetSheetInformationsAsync(Stream stream, Open { config ??= OpenXmlConfiguration.Default; - using var archive = new OpenXmlZip(stream); + var archive = await OpenXmlZip.CreateAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + await using var disposableArchve = archive.ConfigureAwait(false); using var reader = await OpenXmlReader.CreateAsync(stream, config, cancellationToken: cancellationToken).ConfigureAwait(false); - var rels = await reader.GetWorkbookRelsAsync(archive.EntryCollection, cancellationToken).ConfigureAwait(false); + var rels = await reader.GetWorkbookRelsAsync(archive.EntryCollection, cancellationToken).ConfigureAwait(false); return rels?.Select((s, i) => s.ToSheetInfo((uint)i)).ToList() ?? []; } diff --git a/src/MiniExcel.OpenXml/Constants/Schemas.cs b/src/MiniExcel.OpenXml/Constants/Schemas.cs index 35f3f079..88d436a4 100644 --- a/src/MiniExcel.OpenXml/Constants/Schemas.cs +++ b/src/MiniExcel.OpenXml/Constants/Schemas.cs @@ -2,7 +2,7 @@ internal static class Schemas { - public const string SpreadsheetmlXmlNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + public const string SpreadsheetmlXmlMain = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; public const string SpreadsheetmlXmlStrictNs = "http://purl.oclc.org/ooxml/spreadsheetml/main"; public const string OpenXmlPackageRelationships = "http://schemas.openxmlformats.org/package/2006/relationships"; diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index 2313cc6a..a6924561 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -10,7 +10,7 @@ namespace MiniExcelLib.OpenXml; internal partial class OpenXmlReader : IMiniExcelReader { - private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlNs, Schemas.SpreadsheetmlXmlStrictNs]; + private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlMain, Schemas.SpreadsheetmlXmlStrictNs]; private static readonly string[] RelationshiopNs = [Schemas.SpreadsheetmlXmlRelationships, Schemas.SpreadsheetmlXmlStrictRelationships]; private readonly OpenXmlConfiguration _config; @@ -21,9 +21,9 @@ internal partial class OpenXmlReader : IMiniExcelReader internal readonly OpenXmlZip Archive; internal IDictionary SharedStrings = new Dictionary(); - private OpenXmlReader(Stream stream, IMiniExcelConfiguration? configuration) + private OpenXmlReader(OpenXmlZip openXmlZip, IMiniExcelConfiguration? configuration) { - Archive = new OpenXmlZip(stream); + Archive = openXmlZip; _config = (OpenXmlConfiguration?)configuration ?? OpenXmlConfiguration.Default; } @@ -31,8 +31,10 @@ private OpenXmlReader(Stream stream, IMiniExcelConfiguration? configuration) internal static async Task CreateAsync(Stream stream, IMiniExcelConfiguration? configuration, CancellationToken cancellationToken = default) { ThrowHelper.ThrowIfInvalidOpenXml(stream); - - var reader = new OpenXmlReader(stream, configuration); + + var archive = await OpenXmlZip.CreateAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var reader = new OpenXmlReader(archive, configuration); + await reader.SetSharedStringsAsync(cancellationToken).ConfigureAwait(false); return reader; } @@ -1149,7 +1151,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance XNamespace nsRel = Schemas.OpenXmlPackageRelationships; XNamespace ns18Tc = Schemas.SpreadsheetmlXmlX18Tc; - XNamespace nsMain = Schemas.SpreadsheetmlXmlNs; + XNamespace nsMain = Schemas.SpreadsheetmlXmlMain; XNamespace ns14R = Schemas.SpreadsheetmlXmlX14R; SetWorkbookRels(Archive.EntryCollection); diff --git a/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs b/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs index d2390c7c..feecc08a 100644 --- a/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs +++ b/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs @@ -4,10 +4,10 @@ namespace MiniExcelLib.OpenXml; -internal partial class OpenXmlWriter : IMiniExcelWriter +internal partial class OpenXmlWriter { private readonly Dictionary _zipDictionary = []; - private Dictionary _cellXfIdMap; + private Dictionary _cellXfIdMap = []; private IEnumerable> GetSheets() { diff --git a/src/MiniExcel.OpenXml/OpenXmlWriter.cs b/src/MiniExcel.OpenXml/OpenXmlWriter.cs index 9d9e47ad..78a705e1 100644 --- a/src/MiniExcel.OpenXml/OpenXmlWriter.cs +++ b/src/MiniExcel.OpenXml/OpenXmlWriter.cs @@ -23,18 +23,12 @@ internal partial class OpenXmlWriter : IMiniExcelWriter private int _currentSheetIndex = 0; - private OpenXmlWriter(Stream stream, object? value, string? sheetName, IMiniExcelConfiguration? configuration, bool printHeader) + private OpenXmlWriter(Stream stream, ZipArchive archive, object? value, string? sheetName, OpenXmlConfiguration configuration, bool printHeader) { _stream = stream; - // A. Why ZipArchiveMode.Update and not ZipArchiveMode.Create? - // R. ZipArchiveEntry does not support seeking when Mode is Create. - _configuration = configuration as OpenXmlConfiguration ?? OpenXmlConfiguration.Default; - if (_configuration is { EnableAutoWidth: true, FastMode: false }) - throw new InvalidOperationException("Auto width requires fast mode to be enabled"); - - var archiveMode = _configuration.FastMode ? ZipArchiveMode.Update : ZipArchiveMode.Create; - _archive = new ZipArchive(_stream, archiveMode, true, Utf8WithBom); + _configuration = configuration; + _archive = archive; _value = value; _printHeader = printHeader; @@ -42,12 +36,24 @@ private OpenXmlWriter(Stream stream, object? value, string? sheetName, IMiniExce } [CreateSyncVersion] - internal static Task CreateAsync(Stream stream, object? value, string? sheetName, bool printHeader, IMiniExcelConfiguration? configuration, CancellationToken cancellationToken = default) + internal static async ValueTask CreateAsync(Stream stream, object? value, string? sheetName, bool printHeader, IMiniExcelConfiguration? configuration, CancellationToken cancellationToken = default) { ThrowHelper.ThrowIfInvalidSheetName(sheetName); - var writer = new OpenXmlWriter(stream, value, sheetName, configuration, printHeader); - return Task.FromResult(writer); + 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"); + + // A. Why ZipArchiveMode.Update and not ZipArchiveMode.Create? + // R. ZipArchiveEntry does not support seeking when Mode is Create. + var archiveMode = conf.FastMode ? ZipArchiveMode.Update : ZipArchiveMode.Create; + +#if NET10_0_OR_GREATER + var archive = await ZipArchive.CreateAsync(stream, archiveMode, true, Utf8WithBom, cancellationToken).ConfigureAwait(false); +#else + var archive = new ZipArchive(stream, archiveMode, true, Utf8WithBom); +#endif + return new OpenXmlWriter(stream, archive, value, sheetName, conf, printHeader); } [CreateSyncVersion] diff --git a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs index 817a7d86..920330cd 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs @@ -17,16 +17,18 @@ private static XmlNamespaceManager GetRNamespaceManager(XmlDocument doc) public static async Task AddPictureAsync(Stream excelStream, CancellationToken cancellationToken = default, params MiniExcelPicture[] images) { // get sheets - using var excelArchive = new OpenXmlZip(excelStream); + var excelArchive = await OpenXmlZip.CreateAsync(excelStream, cancellationToken: cancellationToken).ConfigureAwait(false); + await using var disposableExcelArchive = excelArchive.ConfigureAwait(false); + using var reader = await OpenXmlReader.CreateAsync(excelStream, null, cancellationToken).ConfigureAwait(false); #if NET10_0_OR_GREATER - var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, true); - await using var disposableArchive = archive.ConfigureAwait(false); + var archive = await ZipArchive.CreateAsync(excelStream, ZipArchiveMode.Update, true, null, cancellationToken).ConfigureAwait(false); + await using var disposableArchive = archive.ConfigureAwait(false); #else using var archive = new ZipArchive(excelStream, ZipArchiveMode.Update, true); #endif - var rels = await reader.GetWorkbookRelsAsync(excelArchive.EntryCollection, cancellationToken).ConfigureAwait(false); + var rels = await reader.GetWorkbookRelsAsync(excelArchive.EntryCollection, cancellationToken).ConfigureAwait(false); var sheetEntries = rels?.ToList() ?? []; // Group images by sheet diff --git a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs index fa11822e..dd55dc1b 100644 --- a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs +++ b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs @@ -5,7 +5,7 @@ namespace MiniExcelLib.OpenXml.Styles; internal class OpenXmlStyles { - private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlNs, Schemas.SpreadsheetmlXmlStrictNs]; + private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlMain, Schemas.SpreadsheetmlXmlStrictNs]; private readonly Dictionary _cellXfs = new(); private readonly Dictionary _cellStyleXfs = new(); diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs index 2c77b1cc..714c6394 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs @@ -1,18 +1,33 @@ using MiniExcelLib.Core.Attributes; -using MiniExcelLib.OpenXml.Constants; using System.ComponentModel; +using System.Xml.Linq; +using MiniExcelLib.OpenXml.Constants; namespace MiniExcelLib.OpenXml.Templates; internal partial class OpenXmlTemplate { - private readonly List _calcChainCellRefs = []; + private static readonly XNamespace SpreadsheetNs = Schemas.SpreadsheetmlXmlMain; + + private static readonly XmlWriterSettings DocXmlWriterSettings = new() + { +#if !SYNC_ONLY + Async = true +#endif + }; - private List _xRowInfos; - private Dictionary _xMergeCellInfos; - private List _newXMergeCellInfos; + private static readonly XmlWriterSettings FragXmlWriterSettings = new() + { + OmitXmlDeclaration = true, + ConformanceLevel = ConformanceLevel.Fragment, +#if !SYNC_ONLY + Async = true +#endif + }; -#if NET7_0_OR_GREATER +#if NET8_0_OR_GREATER + [GeneratedRegex("(?<={{).*?(?=}})")] private static partial Regex ExpressionRegex(); + private static readonly Regex IsExpressionRegex = ExpressionRegex(); [GeneratedRegex("([A-Z]+)([0-9]+)")] private static partial Regex CellRegexImpl(); private static readonly Regex CellRegex = CellRegexImpl(); [GeneratedRegex(@"\{\{(.*?)\}\}")] private static partial Regex TemplateRegexImpl(); @@ -22,113 +37,135 @@ internal partial class OpenXmlTemplate [GeneratedRegex(@"<(?:x:)?v>\s*")] private static partial Regex EmptyVTagRegexImpl(); private static readonly Regex EmptyVTagRegex = EmptyVTagRegexImpl(); #else + private static readonly Regex IsExpressionRegex = new("(?<={{).*?(?=}})"); private static readonly Regex CellRegex = new("([A-Z]+)([0-9]+)", RegexOptions.Compiled); private static readonly Regex TemplateRegex = new(@"\{\{(.*?)\}\}", RegexOptions.Compiled); private static readonly Regex NonTemplateRegex = new(@".*?\{\{.*?\}\}.*?", RegexOptions.Compiled); private static readonly Regex EmptyVTagRegex = new(@"<(?:x:)?v>\s*", RegexOptions.Compiled); #endif + private readonly List _xRowInfos = []; + private readonly Dictionary _xMergeCellInfos = []; + private readonly List _newXMergeCellInfos = []; + private readonly List _calcChainCellRefs = []; + + [CreateSyncVersion] - private async Task GenerateSheetXmlImplByUpdateModeAsync(ZipArchiveEntry sheetZipEntry, Stream stream, Stream sheetStream, IDictionary inputMaps, IDictionary sharedStrings, bool mergeCells = false, CancellationToken cancellationToken = default) + private async Task GenerateSheetByUpdateModeAsync(ZipArchiveEntry sheetZipEntry, Stream stream, Stream sheetStream, IDictionary inputMaps, IDictionary sharedStrings, bool mergeCells = false, CancellationToken cancellationToken = default) { - var doc = new XmlDocument(); - doc.Load(sheetStream); - -#if NET5_0_OR_GREATER +#if NET8_0_OR_GREATER + var doc = await XDocument.LoadAsync(sheetStream, LoadOptions.None, cancellationToken).ConfigureAwait(false); await sheetStream.DisposeAsync().ConfigureAwait(false); #else + var doc = XDocument.Load(sheetStream); sheetStream.Dispose(); #endif - sheetZipEntry.Delete(); // ZipArchiveEntry can't update directly, so need to delete then create logic + // we can't update ZipArchiveEntry directly, so we delete the original entry and recreate it + sheetZipEntry.Delete(); - var worksheet = doc.SelectSingleNode("/x:worksheet", Ns); - var sheetData = doc.SelectSingleNode("/x:worksheet/x:sheetData", Ns); - var newSheetData = sheetData?.Clone(); //avoid delete lost data - var rows = newSheetData?.SelectNodes("x:row", Ns); + var worksheet = doc.Element(SpreadsheetNs + "worksheet"); + var sheetData = worksheet?.Element(SpreadsheetNs + "sheetData"); + var newSheetData = new XElement(sheetData); + var rows = newSheetData.Elements(SpreadsheetNs + "row"); - ReplaceSharedStringsToStr(sharedStrings, rows); - GetMergeCells(doc, worksheet); - UpdateDimensionAndGetRowsInfo(inputMaps, doc, rows, !mergeCells); + InjectSharedStrings(sharedStrings, rows); + GetMergeCells(worksheet); + UpdateDimensionAndGetRowsInfo(inputMaps, worksheet, rows, !mergeCells); - await WriteSheetXmlAsync(stream, doc, sheetData, mergeCells, cancellationToken).ConfigureAwait(false); +#if NET8_0_OR_GREATER + var writer = XmlWriter.Create(stream, DocXmlWriterSettings); + await using var disposableWriter = writer.ConfigureAwait(false); +#else + using var writer = XmlWriter.Create(stream, DocXmlWriterSettings); +#endif + + await WriteSheetXmlAsync(writer, worksheet, sheetData, mergeCells, cancellationToken).ConfigureAwait(false); } - + [CreateSyncVersion] - private async Task GenerateSheetXmlImplByCreateModeAsync(ZipArchiveEntry templateSheetZipEntry, Stream outputZipSheetEntryStream, IDictionary inputMaps, IDictionary sharedStrings, bool mergeCells = false) + private async Task GenerateSheetByCreateModeAsync(ZipArchiveEntry templateSheetZipEntry, Stream outputZipSheetEntryStream, IDictionary inputMaps, IDictionary sharedStrings, bool mergeCells = false, CancellationToken cancellationToken = default) { - var doc = new XmlDocument - { - XmlResolver = null - }; - -#if NET5_0_OR_GREATER +#if NET8_0_OR_GREATER #if NET10_0_OR_GREATER - var newTemplateStream = await templateSheetZipEntry.OpenAsync().ConfigureAwait(false); + var newTemplateStream = await templateSheetZipEntry.OpenAsync(cancellationToken).ConfigureAwait(false); #else var newTemplateStream = templateSheetZipEntry.Open(); #endif - await using var disposableStream = newTemplateStream.ConfigureAwait(false); + await using var disposableNewTemplateStream = newTemplateStream.ConfigureAwait(false); + var doc = await XDocument.LoadAsync(newTemplateStream, LoadOptions.None, cancellationToken).ConfigureAwait(false); #else using var newTemplateStream = templateSheetZipEntry.Open(); + var doc = XDocument.Load(newTemplateStream); #endif - doc.Load(newTemplateStream); + var worksheet = doc.Element(SpreadsheetNs + "worksheet"); + var prefix = worksheet?.GetPrefixOfNamespace(SpreadsheetNs); + if (!string.IsNullOrEmpty(prefix)) + { + // we remove the main namespace's prefix declaration so that we don't have to worry about inconstencies when we serialize single elements + worksheet?.Attribute(XNamespace.Xmlns + prefix)?.Remove(); + } - var worksheet = doc.SelectSingleNode("/x:worksheet", Ns); - var sheetData = doc.SelectSingleNode("/x:worksheet/x:sheetData", Ns); - var newSheetData = sheetData?.Clone(); //avoid delete lost data - var rows = newSheetData?.SelectNodes("x:row", Ns); + var sheetData = worksheet?.Element(SpreadsheetNs + "sheetData"); + var newSheetData = new XElement(sheetData); + + var rows = newSheetData.Elements(SpreadsheetNs + "row"); - ReplaceSharedStringsToStr(sharedStrings, rows); - GetMergeCells(doc, worksheet); - UpdateDimensionAndGetRowsInfo(inputMaps, doc, rows, !mergeCells); + InjectSharedStrings(sharedStrings, rows); + GetMergeCells(worksheet); + UpdateDimensionAndGetRowsInfo(inputMaps, worksheet, rows, !mergeCells); - await WriteSheetXmlAsync(outputZipSheetEntryStream, doc, sheetData, mergeCells).ConfigureAwait(false); +#if NET8_0_OR_GREATER + var writer = XmlWriter.Create(outputZipSheetEntryStream, DocXmlWriterSettings); + await using var disposableWriter = writer.ConfigureAwait(false); +#else + using var writer = XmlWriter.Create(outputZipSheetEntryStream, DocXmlWriterSettings); +#endif + await WriteSheetXmlAsync(writer, worksheet, sheetData, mergeCells, cancellationToken).ConfigureAwait(false); } - private void GetMergeCells(XmlDocument doc, XmlNode worksheet) + private void GetMergeCells(XElement worksheet) { - var mergeCells = doc.SelectSingleNode("/x:worksheet/x:mergeCells", Ns); - if (mergeCells is null) + if (worksheet.Element(SpreadsheetNs + "mergeCells") is not { } mergeCells) return; - var newMergeCells = mergeCells.Clone(); - worksheet.RemoveChild(mergeCells); + var newMergeCells = new XElement(mergeCells); + mergeCells.Remove(); - foreach (XmlElement cell in newMergeCells) + foreach (var cell in newMergeCells.Elements()) { - var mergerCell = new XMergeCell(cell); - _xMergeCellInfos[mergerCell.XY1] = mergerCell; + var mergeCell = new XMergeCell(cell); + _xMergeCellInfos[mergeCell.XY1] = mergeCell; } } - private static IEnumerable ParseConditionalFormatRanges(XmlDocument doc) + private static IEnumerable NewParseConditionalFormatRanges(XElement worksheet) { - var conditionalFormatting = doc.SelectNodes("/x:worksheet/x:conditionalFormatting", Ns); + var conditionalFormatting = worksheet.Element(SpreadsheetNs + "conditionalFormatting"); if (conditionalFormatting is null) yield break; - foreach (XmlNode conditionalFormat in conditionalFormatting) + foreach (var format in conditionalFormatting.Elements()) { - var rangeValues = conditionalFormat.Attributes?["sqref"]?.Value.Split(' '); - if (rangeValues is null) + var ranges = format.Attribute("sqref")?.Value.Split(' '); + if (ranges is null) continue; - var rangeList = new List(); - foreach (var rangeVal in rangeValues) + List rangeList = []; + foreach (var range in ranges) { - var rangeValSplit = rangeVal.Split(':'); - if (rangeValSplit.Length == 0) + var rangeValue = range.Split(':'); + if (rangeValue.Length == 0) continue; - if (rangeValSplit.Length == 1) + if (rangeValue.Length == 1) { - var match = CellRegex.Match(rangeValSplit[0]); - if (!match.Success) + if (CellRegex.Match(rangeValue[0]) is not { Success: true } match) continue; var row = int.Parse(match.Groups[2].Value); var column = CellReferenceConverter.GetNumericalIndex(match.Groups[1].Value); + rangeList.Add(new Range { StartColumn = column, @@ -137,88 +174,80 @@ private static IEnumerable ParseConditionalFormatRanges( EndRow = row }); } - else + else if (CellRegex.Match(rangeValue[0]) is { Success: true } match1 && + CellRegex.Match(rangeValue[1]) is { Success: true } match2) { - var match1 = CellRegex.Match(rangeValSplit[0]); - var match2 = CellRegex.Match(rangeValSplit[1]); - if (match1.Success && match2.Success) + rangeList.Add(new Range { - rangeList.Add(new Range - { - StartColumn = CellReferenceConverter.GetNumericalIndex(match1.Groups[1].Value), - StartRow = int.Parse(match1.Groups[2].Value), - EndColumn = CellReferenceConverter.GetNumericalIndex(match2.Groups[1].Value), - EndRow = int.Parse(match2.Groups[2].Value) - }); - } + StartColumn = CellReferenceConverter.GetNumericalIndex(match1.Groups[1].Value), + StartRow = int.Parse(match1.Groups[2].Value), + EndColumn = CellReferenceConverter.GetNumericalIndex(match2.Groups[1].Value), + EndRow = int.Parse(match2.Groups[2].Value) + }); } } yield return new ConditionalFormatRange { - Node = conditionalFormat, + Node = format, Ranges = rangeList }; } } [CreateSyncVersion] - private async Task WriteSheetXmlAsync(Stream outputFileStream, XmlDocument doc, XmlNode sheetData, bool mergeCells = false, CancellationToken cancellationToken = default) + private async Task WriteSheetXmlAsync(XmlWriter writer, XElement worksheet, XElement sheetData, bool mergeCells = false, CancellationToken cancellationToken = default) { - //Q.Why so complex? - //A.Because try to use string stream avoid OOM when rendering rows + // TODO: Can we make this less complex? - var conditionalFormatRanges = ParseConditionalFormatRanges(doc).ToList(); + var conditionalFormatRanges = NewParseConditionalFormatRanges(worksheet).ToList(); var newConditionalFormatRanges = new List(); newConditionalFormatRanges.AddRange(conditionalFormatRanges); sheetData.RemoveAll(); - sheetData.InnerText = "{{{{{{split}}}}}}"; //TODO: bad code smell - - var prefix = string.IsNullOrEmpty(sheetData.Prefix) ? "" : $"{sheetData.Prefix}:"; - var endPrefix = string.IsNullOrEmpty(sheetData.Prefix) ? "" : $":{sheetData.Prefix}"; // https://user-images.githubusercontent.com/12729184/115000066-fd02b300-9ed4-11eb-8e65-bf0014015134.png - - var conditionalFormatNodes = doc.SelectNodes("/x:worksheet/x:conditionalFormatting", Ns); - for (var i = 0; i < conditionalFormatNodes?.Count; ++i) - { - var node = conditionalFormatNodes.Item(i); - node.ParentNode.RemoveChild(node); - } + worksheet.Elements(SpreadsheetNs + "conditionalFormatting").Remove(); + + var prefix = worksheet.GetPrefixOfNamespace(SpreadsheetNs); + var fullPrefix = !string.IsNullOrEmpty(prefix) ? $"{prefix}:" : ""; - var phoneticPr = doc.SelectSingleNode("/x:worksheet/x:phoneticPr", Ns); var phoneticPrXml = string.Empty; - if (phoneticPr is not null) + if (worksheet.Element(SpreadsheetNs + "phoneticPr") is { } phoneticPr) { - phoneticPrXml = phoneticPr.OuterXml; - phoneticPr.ParentNode.RemoveChild(phoneticPr); + phoneticPrXml = phoneticPr.ToString(SaveOptions.DisableFormatting); + phoneticPr.Remove(); } // Extract autoFilter - must be written before mergeCells and phoneticPr per ECMA-376 - var autoFilter = doc.SelectSingleNode("/x:worksheet/x:autoFilter", Ns); var autoFilterXml = string.Empty; - if (autoFilter is not null) + if (worksheet.Element(SpreadsheetNs + "autoFilter") is { } autoFilter) { - autoFilterXml = autoFilter.OuterXml; - autoFilter.ParentNode.RemoveChild(autoFilter); + autoFilterXml = autoFilter.ToString(SaveOptions.DisableFormatting); + autoFilter.Remove(); } - var contents = doc.InnerXml.Split(new[] { $"<{prefix}sheetData>{{{{{{{{{{{{split}}}}}}}}}}}}" }, StringSplitOptions.None); -#if NETCOREAPP3_0_OR_GREATER - var writer = new StreamWriter(outputFileStream, Encoding.UTF8); - await using var disposableWriter = writer.ConfigureAwait(false); + var beforeSheetData = worksheet.Element(SpreadsheetNs + "sheetData")?.ElementsBeforeSelf() ?? []; + var afterSheetData = worksheet.Element(SpreadsheetNs + "sheetData")?.ElementsAfterSelf() ?? []; + + await writer.WriteStartElementAsync(null, "worksheet", Schemas.SpreadsheetmlXmlMain).ConfigureAwait(false); + foreach (var attr in worksheet.Attributes()) + { + var (nsPrefix, ns) = attr is { IsNamespaceDeclaration: true, Name.LocalName: not "xmlns" } + ? ("xmlns", null as string) + : (null, attr.Name.NamespaceName); + + await writer.WriteAttributeStringAsync(nsPrefix, attr.Name.LocalName, ns, attr.Value).ConfigureAwait(false); + } + + foreach (var beforeElement in beforeSheetData) + { +#if NET8_0_OR_GREATER + await beforeElement.WriteToAsync(writer, cancellationToken).ConfigureAwait(false); #else - using var writer = new StreamWriter(outputFileStream, Encoding.UTF8); -#endif - await writer.WriteAsync(contents[0] -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken + beforeElement.WriteTo(writer); #endif - ).ConfigureAwait(false); - await writer.WriteAsync($"<{prefix}sheetData>" -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); // prefix problem + } + + await writer.WriteStartElementAsync(null,"sheetData", Schemas.SpreadsheetmlXmlMain).ConfigureAwait(false); if (mergeCells) { @@ -228,7 +257,6 @@ await writer.WriteAsync($"<{prefix}sheetData>" #region Generate rows and cells int rowIndexDiff = 0; - var rowXml = new StringBuilder(); // for formula cells int enumrowstart = -1; @@ -238,7 +266,7 @@ await writer.WriteAsync($"<{prefix}sheetData>" bool groupingStarted = false; bool hasEverGroupStarted = false; int groupStartRowIndex = 0; - IList cellIEnumerableValues = null; + IList? cellIEnumerableValues = null; bool isCellIEnumerableValuesSet = false; int cellIEnumerableValuesIndex = 0; int groupRowCount = 0; @@ -256,9 +284,9 @@ await writer.WriteAsync($"<{prefix}sheetData>" var row = rowInfo.Row; SpecialCellType specialCellType = default; - foreach (XmlNode c in row.GetElementsByTagName("c")) + foreach (var cell in row.Elements(SpreadsheetNs + "c")) { - specialCellType = c.InnerText switch + specialCellType = cell.Value switch { "@group" => SpecialCellType.Group, "@endgroup" => SpecialCellType.Endgroup, @@ -282,7 +310,7 @@ var s when s.StartsWith("@header") => SpecialCellType.Header, } else if (specialCellType == SpecialCellType.Endgroup) { - if (cellIEnumerableValuesIndex >= cellIEnumerableValues.Count - 1) + if (cellIEnumerableValuesIndex >= cellIEnumerableValues?.Count - 1) { groupingStarted = false; groupStartRowIndex = 0; @@ -301,18 +329,15 @@ var s when s.StartsWith("@header") => SpecialCellType.Header, { isHeaderRow = true; } - else if (mergeCells) + else if (mergeCells && specialCellType == SpecialCellType.Merge) { - if (specialCellType == SpecialCellType.Merge) - { - mergeRowCount++; - continue; - } + mergeRowCount++; + continue; } if (groupingStarted && !isCellIEnumerableValuesSet) { - cellIEnumerableValues = rowInfo.CellIlListValues ?? rowInfo.CellIEnumerableValues.Cast().ToList(); + cellIEnumerableValues = rowInfo.CellIlListValues ?? rowInfo.CellIEnumerableValues?.Cast().ToList() ?? []; isCellIEnumerableValuesSet = true; } @@ -336,67 +361,68 @@ var s when s.StartsWith("@header") => SpecialCellType.Header, } } - //TODO: some xlsx without r - var originRowIndex = int.Parse(row.GetAttribute("r")); + //TODO: Fix parsing for documents that don't have the "r" attribute on rows + if (row.Attribute("r")?.Value is not { } rVal || !int.TryParse(rVal, out var originRowIndex)) + throw new NotSupportedException("The format of the chosen template is not currently supported."); + var newRowIndex = originRowIndex + rowIndexDiff + groupingRowDiff - mergeRowCount; + var innerXml = string.Concat(row.Nodes()); + + var rowXml = new StringBuilder("<"); + if (!string.IsNullOrEmpty(prefix)) + rowXml.Append($"{prefix}:"); + + rowXml.Append(row.Name.LocalName); - string innerXml = row.InnerXml; - rowXml.Clear().AppendFormat("<{0}", row.Name); - foreach (XmlAttribute attr in row.Attributes) + foreach (var attr in row.Attributes()) { - if (attr.Name != "r") - rowXml.AppendFormat(@" {0}=""{1}""", attr.Name, attr.Value); + if (attr is + { + Name: { LocalName: var name and not "r", Namespace: var ns }, + Value: var value + }) + { + var pfx = worksheet.GetPrefixOfNamespace(ns); + var fullName = string.IsNullOrEmpty(pfx) ? name : $"{pfx}:{name}"; + rowXml.Append($" {fullName}=\"{value}\""); + } } - var outerXmlOpen = new StringBuilder(); - outerXmlOpen.Append(rowXml); - + var outerXmlOpen = new StringBuilder().Append(rowXml); if (rowInfo.CellIEnumerableValues is not null) { - var isFirst = true; - var iEnumerableIndex = 0; enumrowstart = newRowIndex; - - var generateCellValuesContext = new GenerateCellValuesContext() + var generateCellValuesContext = new GenerateCellValuesContext { CurrentHeader = currentHeader, HeaderDiff = headerDiff, - EnumerableIndex = iEnumerableIndex, - IsFirst = isFirst, + EnumerableIndex = 0, + IsFirst = true, NewRowIndex = newRowIndex, PrevHeader = prevHeader, RowIndexDiff = rowIndexDiff, }; - generateCellValuesContext = await GenerateCellValuesAsync(generateCellValuesContext, endPrefix, writer, rowXml, mergeRowCount, isHeaderRow, rowInfo, row, groupingRowDiff, innerXml, outerXmlOpen, row, cancellationToken).ConfigureAwait(false); + generateCellValuesContext = await GenerateCellValuesAsync(generateCellValuesContext, prefix, writer, rowXml, mergeRowCount, isHeaderRow, rowInfo, row, groupingRowDiff, innerXml, outerXmlOpen, row, cancellationToken).ConfigureAwait(false); rowIndexDiff = generateCellValuesContext.RowIndexDiff; headerDiff = generateCellValuesContext.HeaderDiff; prevHeader = generateCellValuesContext.PrevHeader; newRowIndex = generateCellValuesContext.NewRowIndex; - isFirst = generateCellValuesContext.IsFirst; - iEnumerableIndex = generateCellValuesContext.EnumerableIndex; - currentHeader = generateCellValuesContext.CurrentHeader; enumrowend = newRowIndex - 1; var conditionalFormats = conditionalFormatRanges.Where(cfr => cfr.Ranges.Any(r => r.ContainsRow(originRowIndex))); foreach (var conditionalFormat in conditionalFormats) { - var newConditionalFormat = conditionalFormat.Node.Clone(); - var sqref = newConditionalFormat.Attributes["sqref"]; + var newConditionalFormat = new XElement(conditionalFormat.Node); + var sqref = newConditionalFormat.Attribute("sqref"); var ranges = conditionalFormat.Ranges .Where(r => r.ContainsRow(originRowIndex)) - .Select(r => new Range - { - StartColumn = r.StartColumn, - StartRow = enumrowstart, - EndColumn = r.EndColumn, - EndRow = enumrowend - }) + .Select(r => r with { StartRow = enumrowstart, EndRow = enumrowend }) .ToList(); - sqref.Value = string.Join(" ", ranges.Select(r => $"{CellReferenceConverter.GetAlphabeticalIndex(r.StartColumn)}{r.StartRow}:{CellReferenceConverter.GetAlphabeticalIndex(r.EndColumn)}{r.EndRow}")); + sqref?.Value = string.Join(" ", ranges.Select(r => $"{CellReferenceConverter.GetAlphabeticalIndex(r.StartColumn)}{r.StartRow}:{CellReferenceConverter.GetAlphabeticalIndex(r.EndColumn)}{r.EndRow}")); newConditionalFormatRanges.Remove(conditionalFormat); newConditionalFormatRanges.Add(new ConditionalFormatRange { @@ -409,19 +435,15 @@ var s when s.StartsWith("@header") => SpecialCellType.Header, { rowXml.Clear() .Append(outerXmlOpen) - .AppendFormat(@" r=""{0}"">", newRowIndex) + .Append($@" r=""{newRowIndex}"">") .Append(innerXml) .Replace("{{$rowindex}}", newRowIndex.ToString()) .Replace("{{$enumrowstart}}", enumrowstart.ToString()) .Replace("{{$enumrowend}}", enumrowend.ToString()) - .AppendFormat("", row.Name); + .Append($""); ProcessFormulas(rowXml, newRowIndex); - await writer.WriteAsync(CleanXml(rowXml, endPrefix).ToString() -#if NET5_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteRawAsync(CleanXml(rowXml, prefix).ToString()).ConfigureAwait(false); //mergecells if (rowInfo.RowMercells is null) @@ -440,89 +462,66 @@ await writer.WriteAsync(CleanXml(rowXml, endPrefix).ToString() #endregion - await writer.WriteAsync($"" -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteEndElementAsync().ConfigureAwait(false); // ECMA-376 element order: sheetData → autoFilter → mergeCells → phoneticPr → conditionalFormatting - // 1. autoFilter (must come before mergeCells) if (!string.IsNullOrEmpty(autoFilterXml)) { - await writer.WriteAsync(CleanXml(autoFilterXml, endPrefix) -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteRawAsync(CleanXml(autoFilterXml, prefix)).ConfigureAwait(false); } // 2. mergeCells if (_newXMergeCellInfos.Count != 0) { - await writer.WriteAsync($"<{prefix}mergeCells count=\"{_newXMergeCellInfos.Count}\">" -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteRawAsync($"<{fullPrefix}mergeCells count=\"{_newXMergeCellInfos.Count}\">").ConfigureAwait(false); foreach (var cell in _newXMergeCellInfos) { - await writer.WriteAsync(cell.ToXmlString(prefix) -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteRawAsync(cell.ToXmlString(prefix)).ConfigureAwait(false); } - await writer.WriteLineAsync($"" -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteRawAsync($"\r\n").ConfigureAwait(false); } // 3. phoneticPr if (!string.IsNullOrEmpty(phoneticPrXml)) { - await writer.WriteAsync(CleanXml(phoneticPrXml, endPrefix) -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteRawAsync(CleanXml(phoneticPrXml, prefix)).ConfigureAwait(false); } // 4. conditionalFormatting if (newConditionalFormatRanges.Count != 0) { - await writer.WriteAsync(CleanXml(string.Join(string.Empty, newConditionalFormatRanges.Select(cf => cf.Node.OuterXml)), endPrefix) -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + var nodes = newConditionalFormatRanges.Select(cf => cf.Node?.ToString(SaveOptions.DisableFormatting)); + await writer.WriteRawAsync(CleanXml(string.Join("", nodes), prefix)).ConfigureAwait(false); } - await writer.WriteAsync(contents[1] -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken + foreach (var afterElement in afterSheetData) + { +#if NET8_0_OR_GREATER + await afterElement.WriteToAsync(writer, cancellationToken).ConfigureAwait(false); +#else + afterElement.WriteTo(writer); #endif - ).ConfigureAwait(false); + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); } //todo: refactor in a way that needs less parameters [CreateSyncVersion] private async Task GenerateCellValuesAsync( GenerateCellValuesContext generateCellValuesContext, - string endPrefix, - StreamWriter writer, + string? endPrefix, + XmlWriter writer, StringBuilder rowXml, int mergeRowCount, bool isHeaderRow, XRowInfo rowInfo, - XmlElement row, + XElement row, int groupingRowDiff, - string innerXml, + string innerXml, StringBuilder outerXmlOpen, - XmlElement rowElement, + XElement rowElement, CancellationToken cancellationToken = default) { var rowIndexDiff = generateCellValuesContext.RowIndexDiff; @@ -532,36 +531,37 @@ private async Task GenerateCellValuesAsync( var isFirst = generateCellValuesContext.IsFirst; var iEnumerableIndex = generateCellValuesContext.EnumerableIndex; var currentHeader = generateCellValuesContext.CurrentHeader; - + // https://github.com/mini-software/MiniExcel/issues/771 Saving by template introduces unintended value replication in each row #771 - var notFirstRowElement = rowElement.Clone(); - foreach (XmlElement c in notFirstRowElement.SelectNodes("x:c", Ns)) + var notFirstRowElement = new XElement(rowElement); + foreach (var cell in notFirstRowElement.Elements(SpreadsheetNs + "c")) { // Try first (for t="n"/t="b" cells), then (for t="inlineStr" cells) - var vTag = c.SelectSingleNode("x:v", Ns); - if (vTag is not null) + if (cell.Element(SpreadsheetNs + "v") is { } vTag) { - if (!NonTemplateRegex.IsMatch(vTag.InnerText)) - vTag.InnerText = string.Empty; + if (!NonTemplateRegex.IsMatch(vTag.Value)) + vTag.Value = string.Empty; } else { // Handle inline string cells - var t = c.SelectSingleNode("x:is/x:t", Ns); - if (t is not null && !NonTemplateRegex.IsMatch(t.InnerText)) - t.InnerText = string.Empty; + var t = cell.Element(SpreadsheetNs + "is")?.Element(SpreadsheetNs + "t"); + if (t is not null && !NonTemplateRegex.IsMatch(t.Value)) + t.Value = string.Empty; } } foreach (var item in rowInfo.CellIEnumerableValues) { iEnumerableIndex++; + var closingTag = !string.IsNullOrEmpty(endPrefix) ? $"{endPrefix}:" : ""; + rowXml.Clear() .Append(outerXmlOpen) - .AppendFormat(@" r=""{0}"">", newRowIndex) + .Append($@" r=""{newRowIndex}"">") .Append(innerXml) .Replace("{{$rowindex}}", newRowIndex.ToString()) - .AppendFormat("", row.Name); + .Append($""); var rowXmlString = rowXml.ToString(); var extract = ""; @@ -577,7 +577,7 @@ private async Task GenerateCellValuesAsync( var lines = extract.Split('\n'); var isDictOrTable = rowInfo.IsDictionary || rowInfo.IsDataTable; - var dict = item as IDictionary; + var dict = item as IDictionary; var dataRow = item as DataRow; for (var i = 0; i < lines.Length; i++) @@ -601,17 +601,16 @@ private async Task GenerateCellValuesAsync( } else { - var map = rowInfo.MembersMap[newLines[0]]; - value = map.PropertyInfoOrFieldInfo switch + var prop = rowInfo.PropsMap[newLines[0]]; + value = prop.PropertyInfoOrFieldInfo switch { - PropertyInfoOrFieldInfo.PropertyInfo => map.PropertyInfo.GetValue(item), - PropertyInfoOrFieldInfo.FieldInfo => map.FieldInfo.GetValue(item), + PropertyInfoOrFieldInfo.PropertyInfo => prop.PropertyInfo.GetValue(item), + PropertyInfoOrFieldInfo.FieldInfo => prop.FieldInfo.GetValue(item), _ => string.Empty }; } - var evaluation = EvaluateStatement(value, newLines[1], newLines[2]); - if (evaluation) + if (EvaluateStatement(value, newLines[1], newLines[2])) { newCellValue += lines[i + 1]; break; @@ -638,23 +637,26 @@ private async Task GenerateCellValuesAsync( else { var replacements = new Dictionary(); +#if NET8_0_OR_GREATER string MatchDelegate(Match x) => replacements.GetValueOrDefault(x.Groups[1].Value, ""); - - foreach (var map in rowInfo.MembersMap) +#else + string MatchDelegate(Match x) => replacements.TryGetValue(x.Groups[1].Value, out var repl) ? repl : ""; +#endif + foreach (var prop in rowInfo.PropsMap) { - var propInfo = map.Value.PropertyInfo; - var name = isDictOrTable ? map.Key : propInfo.Name; + var propInfo = prop.Value.PropertyInfo; + var name = isDictOrTable ? prop.Key : propInfo.Name; var key = $"{rowInfo.IEnumerablePropName}.{name}"; object? cellValue; if (rowInfo.IsDictionary) { - if (!dict!.TryGetValue(map.Key, out cellValue)) + if (!dict!.TryGetValue(prop.Key, out cellValue)) continue; } else if (rowInfo.IsDataTable) { - cellValue = dataRow![map.Key]; + cellValue = dataRow![prop.Key]; } else { @@ -665,7 +667,7 @@ private async Task GenerateCellValuesAsync( continue; var type = isDictOrTable - ? map.Value.UnderlyingMemberType + ? prop.Value.UnderlyingMemberType : Nullable.GetUnderlyingType(propInfo.PropertyType) ?? propInfo.PropertyType; string? cellValueStr; @@ -705,7 +707,7 @@ private async Task GenerateCellValuesAsync( replacements[key] = replacementValue; rowXml.Replace($"@header{{{{{key}}}}}", replacementValue); - if (isHeaderRow && row.InnerText.Contains(key)) + if (isHeaderRow && row.Value.Contains(key)) { currentHeader += cellValueStr; } @@ -736,12 +738,12 @@ private async Task GenerateCellValuesAsync( // note: only first time need add diff https://user-images.githubusercontent.com/12729184/114494728-6bceda80-9c4f-11eb-9685-8b5ed054eabe.png if (!isFirst) + { rowIndexDiff += rowInfo.IEnumerableMercell?.Height ?? 1; //TODO:base on the merge size - - if (isFirst) + } + else { - // https://github.com/mini-software/MiniExcel/issues/771 Saving by template introduces unintended value replication in each row #771 - innerXml = notFirstRowElement.InnerXml; + innerXml = string.Concat(notFirstRowElement.Nodes()); isFirst = false; } @@ -754,11 +756,7 @@ private async Task GenerateCellValuesAsync( // replace formulas ProcessFormulas(rowXml, newRowIndex); var finalXml = CleanXml(rowXml, endPrefix).ToString(); - await writer.WriteAsync(finalXml -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteRawAsync(finalXml).ConfigureAwait(false); //mergecells if (rowInfo.RowMercells is null) @@ -784,27 +782,23 @@ await writer.WriteAsync(finalXml for (int i = 1; i < height; i++) { mergeBaseRowIndex++; - var newRow = row.Clone() as XmlElement; - newRow.SetAttribute("r", mergeBaseRowIndex.ToString()); - var cs = newRow.SelectNodes("x:c", Ns); + var newRow = new XElement(row); + newRow.SetAttributeValue("r", mergeBaseRowIndex.ToString()); + + var oldCells = row.Elements(SpreadsheetNs + "c"); + var newCells = newRow.Elements(SpreadsheetNs + "c"); + // all v replace by empty // TODO: remove c/v - foreach (XmlElement c in cs) + foreach (var (newCell, oldCell) in newCells.Zip(oldCells, (x1, x2) => (x1, x2))) { - c.RemoveAttribute("t"); - foreach (XmlNode ch in c.ChildNodes) - { - c.RemoveChild(ch); - } + newCell.Attribute("t")?.Remove(); + newCell.RemoveNodes(); + newCell.Value = oldCell.Value.Replace("{{$rowindex}}", mergeBaseRowIndex.ToString()); } - newRow.InnerXml = new StringBuilder(newRow.InnerXml).Replace("{{$rowindex}}", mergeBaseRowIndex.ToString()).ToString(); - await writer.WriteAsync(CleanXml(newRow.OuterXml, endPrefix) -#if NET7_0_OR_GREATER - .AsMemory(), cancellationToken -#endif - ).ConfigureAwait(false); + await writer.WriteRawAsync(CleanXml(newRow.ToString(), endPrefix)).ConfigureAwait(false); } } @@ -824,24 +818,24 @@ private static void MergeCells(List xRowInfos) { var mergeTaggedColumns = new Dictionary(); var columns = xRowInfos - .SelectMany(s => s.Row.Cast()) - .Where(s => !string.IsNullOrEmpty(s.InnerText)) + .SelectMany(s => s.Row.Elements(SpreadsheetNs + "c")) + .Where(s => !string.IsNullOrEmpty(s.Value)) .Select(s => { - var att = s.GetAttribute("r"); + var att = s.Attribute("r"); return new XChildNode { - InnerText = s.InnerText, - ColIndex = StringHelper.GetLetters(att), - RowIndex = StringHelper.GetNumber(att) + InnerText = s.Value, + ColIndex = StringHelper.GetLetters(att.Value), + RowIndex = StringHelper.GetNumber(att.Value) }; }) .OrderBy(x => x.RowIndex) .ToList(); - var mergeColumns = columns.Where(s => s.InnerText.Contains("@merge")).ToList(); - var endMergeColumns = columns.Where(s => s.InnerText.Contains("@endmerge")).ToList(); - var mergeLimitColumn = mergeColumns.FirstOrDefault(x => x.InnerText.Contains("@mergelimit")); + var mergeColumns = columns.Where(s => s.InnerText?.Contains("@merge") is true).ToList(); + var endMergeColumns = columns.Where(s => s.InnerText?.Contains("@endmerge") is true).ToList(); + var mergeLimitColumn = mergeColumns.FirstOrDefault(x => x.InnerText?.Contains("@mergelimit") is true); foreach (var mergeColumn in mergeColumns) { @@ -861,7 +855,8 @@ private static void MergeCells(List xRowInfos) foreach (var taggedColumn in mergeTaggedColumns) { calculatedColumns.AddRange(columns.Where(x => - x.ColIndex == taggedColumn.Key.ColIndex && x.RowIndex > taggedColumn.Key.RowIndex && + x.ColIndex == taggedColumn.Key.ColIndex && + x.RowIndex > taggedColumn.Key.RowIndex && x.RowIndex < taggedColumn.Value.RowIndex)); } @@ -869,20 +864,20 @@ private static void MergeCells(List xRowInfos) foreach (var rowInfo in xRowInfos) { var row = rowInfo.Row; - var childNodes = row.ChildNodes.Cast(); + var childNodes = row.Elements(); - foreach (var childNode in childNodes) + foreach (var node in childNodes) { - var att = childNode.GetAttribute("r"); - var childNodeLetter = StringHelper.GetLetters(att); - var childNodeNumber = StringHelper.GetNumber(att); + var att = node.Attribute("r"); + var nodeLetter = StringHelper.GetLetters(att.Value); + var nodeNumber = StringHelper.GetNumber(att.Value); - if (!string.IsNullOrEmpty(childNode.InnerText)) + if (!string.IsNullOrEmpty(node.Value)) { var xmlNodes = calculatedColumns .Where(j => - j.InnerText == childNode.InnerText && - j.ColIndex == childNodeLetter) + j.InnerText == node.Value && + j.ColIndex == nodeLetter) .OrderBy(s => s.RowIndex) .ToList(); @@ -891,7 +886,7 @@ private static void MergeCells(List xRowInfos) if (mergeLimitColumn is not null) { var limitedNode = calculatedColumns.First(j => - j.ColIndex == mergeLimitColumn.ColIndex && j.RowIndex == childNodeNumber); + j.ColIndex == mergeLimitColumn.ColIndex && j.RowIndex == nodeNumber); var limitedMaxNode = calculatedColumns.Last(j => j.ColIndex == mergeLimitColumn.ColIndex && j.InnerText == limitedNode.InnerText); @@ -926,59 +921,51 @@ private static void MergeCells(List xRowInfos) } } - childNode.SetAttribute("r", $"{childNodeLetter}{{{{$rowindex}}}}"); + node.SetAttributeValue("r", $"{nodeLetter}{{{{$rowindex}}}}"); } } } private void ProcessFormulas(StringBuilder rowXml, int rowIndex) { - var rowXmlString = rowXml.ToString(); - - // exit early if possible - if (!rowXmlString.Contains("$=")) + // exit if no formula is found + if (!rowXml.ToString().Contains("$=")) return; - var settings = new XmlReaderSettings { NameTable = Ns.NameTable }; - var context = new XmlParserContext(null, Ns, "", XmlSpace.Default); - - using var reader = XmlReader.Create(new StringReader(rowXmlString), settings, context); - var d = new XmlDocument(); - d.Load(reader); + // adding dummy element for correctly parsing namespace prefix + rowXml.Insert(0, $""); + rowXml.Append(""); - var row = d.FirstChild as XmlElement; + var rowElement = XElement.Parse(rowXml.ToString()); - // convert cells starting with '$=' into formulas - var cs = row.SelectNodes("x:c", Ns); - for (var ci = 0; ci < cs.Count; ci++) + var index = 1; + foreach (var cell in rowElement.Descendants(SpreadsheetNs + "c")) { - var c = cs.Item(ci) as XmlElement; - if (c is null) - continue; - + // convert cells starting with '$=' into formulas /* Target: SUM(C2:C7) */ - var vs = c.SelectNodes("x:is", Ns); - foreach (XmlElement v in vs) + foreach (var str in cell.Elements(SpreadsheetNs + "is")) { - if (!v.InnerText.StartsWith("$=")) - continue; - - var fNode = c.OwnerDocument.CreateElement("f", Schemas.SpreadsheetmlXmlNs); - fNode.InnerText = v.InnerText[2..]; - c.InsertBefore(fNode, v); - c.RemoveChild(v); + if (str.Value.StartsWith("$=")) + { + var fNode = new XElement(SpreadsheetNs + "f"); + fNode.SetValue(str.Value[2..]); + str.AddBeforeSelf(fNode); + str.Remove(); - var celRef = CellReferenceConverter.GetCellFromCoordinates(ci + 1, rowIndex); - _calcChainCellRefs.Add(celRef); + var celRef = CellReferenceConverter.GetCellFromCoordinates(index, rowIndex); + _calcChainCellRefs.Add(celRef); + } } + + index++; } rowXml.Clear(); - rowXml.Append(row.OuterXml); + rowXml.Append(rowElement.FirstNode); } private static string? ConvertToDateTimeString(PropertyInfo? propInfo, object cellValue) @@ -992,88 +979,75 @@ private void ProcessFormulas(StringBuilder rowXml, int rowIndex) } //TODO: need to optimize - private static string CleanXml(string xml, string endPrefix) => CleanXml(new StringBuilder(xml), endPrefix).ToString(); - private static StringBuilder CleanXml(StringBuilder xml, string endPrefix) => xml - .Replace("xmlns:x14ac=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac\"", "") - .Replace($"xmlns{endPrefix}=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"", "") - .Replace("xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"", ""); + private static string CleanXml(string? xml, string? prefix) => CleanXml(new StringBuilder(xml), prefix).ToString(); + private static StringBuilder CleanXml(StringBuilder xml, string? prefix = null) + { + var sb = xml + .Replace("xmlns:x14ac=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac\"", "") + .Replace("xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"", ""); - private static void ReplaceSharedStringsToStr(IDictionary sharedStrings, XmlNodeList rows) + return !string.IsNullOrEmpty(prefix) + ? sb.Replace($"xmlns:{prefix}=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"", "") + : sb; + } + + private static void InjectSharedStrings(IDictionary sharedStrings, IEnumerable rows) { - foreach (XmlElement row in rows) + foreach (var row in rows) { - var cs = row.SelectNodes("x:c", Ns); - foreach (XmlElement c in cs) + var cells = row.Elements(SpreadsheetNs + "c"); + foreach (var cell in cells) { - var t = c.GetAttribute("t"); - var v = c.SelectSingleNode("x:v", Ns); - if (v?.InnerText is null) //![image](https://user-images.githubusercontent.com/12729184/114363496-075a3f80-9bab-11eb-9883-8e3fec10765c.png) - continue; - - if (t != "s") + var t = cell.Attribute("t"); + var v = cell.Element(SpreadsheetNs + "v"); + + if (v?.Value is null || t?.Value != "s") continue; - //need to check sharedstring exist or not - if (sharedStrings is null || !sharedStrings.TryGetValue(int.Parse(v.InnerText), out var shared)) + //needs to check if sharedstring exists or not + if (sharedStrings is null || !sharedStrings.TryGetValue(int.Parse(v.Value), out var shared)) continue; // change type = inlineStr and replace its value - // Use the same prefix as the source element to handle namespaced documents (e.g., x:v -> x:is, x:t) - var prefix = v.Prefix; - c.RemoveChild(v); - var isNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlNs) - : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlNs); - var tNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) - : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); - tNode.InnerText = shared; - isNode.AppendChild(tNode); - c.AppendChild(isNode); - - c.RemoveAttribute("t"); - c.SetAttribute("t", "inlineStr"); + v.Remove(); + + var tNode = new XElement(SpreadsheetNs + "t", shared); + var isNode = new XElement(SpreadsheetNs + "is", tNode); + cell.Add(isNode); + cell.SetAttributeValue("t", "inlineStr"); } } } - private static void SetCellType(XmlElement c, string type) + private static void SetCellType(XElement cell, string type) { - if (type == "str") type = "inlineStr"; // Force inlineStr for strings - - // Determine the prefix used in this document (e.g., "x" for x:c, x:v, etc.) - var prefix = c.Prefix; + // Force inlineStr for strings + if (type == "str") + type = "inlineStr"; if (type == "inlineStr") { // Ensure ... - c.SetAttribute("t", "inlineStr"); - var v = c.SelectSingleNode("x:v", Ns); - if (v != null) + cell.SetAttributeValue("t", "inlineStr"); + + if (cell.Element(SpreadsheetNs + "v") is { } v) { - var text = v.InnerText; - c.RemoveChild(v); - var isNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlNs) - : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlNs); - var tNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) - : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); - tNode.InnerText = text; - isNode.AppendChild(tNode); - c.AppendChild(isNode); + var text = v.Value; + v.Remove(); + + var tNode = new XElement(SpreadsheetNs + "t", text); + var isNode = new XElement(SpreadsheetNs + "is", tNode); + + cell.Add(isNode); + cell.SetAttributeValue("t", "inlineStr"); } - else if (c.SelectSingleNode("x:is", Ns) == null) + else if (cell.Element(SpreadsheetNs + "is") is null) { // Create empty if neither nor exists - var isNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlNs) - : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlNs); - var tNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) - : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); - isNode.AppendChild(tNode); - c.AppendChild(isNode); + var tNode = new XElement(SpreadsheetNs + "t"); + var isNode = new XElement(SpreadsheetNs + "is", tNode); + + cell.Add(isNode); } } else @@ -1082,60 +1056,56 @@ private static void SetCellType(XmlElement c, string type) // For numbers/booleans, we remove 't' attribute to let it be default (number) // or we could set it to 'n' explicitly, but removing is safer for general number types if (type == "b") - c.SetAttribute("t", "b"); + cell.SetAttributeValue("t", "b"); else - c.RemoveAttribute("t"); + cell.Attribute("t")?.Remove(); - var isNode = c.SelectSingleNode("x:is", Ns); - if (isNode != null) + if (cell.Element(SpreadsheetNs + "is") is { } isNode) { - var tNode = isNode.SelectSingleNode("x:t", Ns); - var text = tNode?.InnerText ?? string.Empty; - c.RemoveChild(isNode); - var v = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("v", Schemas.SpreadsheetmlXmlNs) - : c.OwnerDocument.CreateElement(prefix, "v", Schemas.SpreadsheetmlXmlNs); - v.InnerText = text; - c.AppendChild(v); + var tNode = isNode.Element(SpreadsheetNs + "t"); + var text = tNode?.Value ?? string.Empty; + isNode.Remove(); + + cell.Add(new XElement(SpreadsheetNs + "v", text)); } } } - private void UpdateDimensionAndGetRowsInfo(IDictionary inputMaps, XmlDocument doc, XmlNodeList rows, bool changeRowIndex = true) + private void UpdateDimensionAndGetRowsInfo(IDictionary inputMaps, XElement worksheet, IEnumerable rows, bool changeRowIndex = true) { string[] refs; - if (doc.SelectSingleNode("/x:worksheet/x:dimension", Ns) is XmlElement dimension) + if (worksheet.Element(SpreadsheetNs + "dimension") is { } dimension && + dimension.Attribute("ref") is { Value: var @ref }) { - refs = dimension.GetAttribute("ref").Split(':'); + refs = @ref.Split(':'); } else { // ==== add dimension element if not found ==== - var firstRow = rows[0].SelectNodes("x:c", Ns); - var lastRow = rows[^1].SelectNodes("x:c", Ns); + var firstCell = rows.FirstOrDefault()?.Elements(SpreadsheetNs + "c").FirstOrDefault(); + var lastCell = rows.LastOrDefault()?.Elements(SpreadsheetNs + "c").LastOrDefault(); - var dimStart = ((XmlElement?)firstRow?[0])?.GetAttribute("r") ?? ""; - var dimEnd = ((XmlElement?)lastRow?[^1])?.GetAttribute("r") ?? ""; + var dimStart = firstCell?.Attribute("r")?.Value ?? ""; + var dimEnd = lastCell?.Attribute("r")?.Value ?? ""; refs = [dimStart, dimEnd]; - dimension = (XmlElement)doc.CreateNode(XmlNodeType.Element, "dimension", null); - var worksheet = doc.SelectSingleNode("/x:worksheet", Ns); - worksheet?.InsertBefore(dimension, worksheet.FirstChild); + dimension = new XElement(SpreadsheetNs + "dimension"); + worksheet.AddFirst(dimension); } var maxRowIndexDiff = 0; - foreach (XmlElement row in rows) + foreach (var row in rows) { // ==== get ienumerable infomation & maxrowindexdiff ==== var xRowInfo = new XRowInfo { Row = row }; _xRowInfos.Add(xRowInfo); - foreach (XmlElement c in row.SelectNodes("x:c", Ns)) + foreach (var cell in row.Elements(SpreadsheetNs + "c")) { - var r = c.GetAttribute("r"); + var r = cell.Attribute("r")?.Value; // ==== mergecells ==== if (_xMergeCellInfos.TryGetValue(r, out var merCell)) @@ -1146,36 +1116,36 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap if (changeRowIndex) { - c.SetAttribute("r", $"{StringHelper.GetLetters(r)}{{{{$rowindex}}}}"); + cell.SetAttributeValue("r", $"{StringHelper.GetLetters(r)}{{{{$rowindex}}}}"); } - var v = c.SelectSingleNode("x:v", Ns) ?? c.SelectSingleNode("x:is/x:t", Ns); - if (v?.InnerText is null) + var v = cell.Element(SpreadsheetNs + "v") ?? cell.Element(SpreadsheetNs + "is")?.Element(SpreadsheetNs + "t"); + if (v?.Value is null) continue; - var matches = IsExpressionRegex.Matches(v.InnerText) + var matches = IsExpressionRegex.Matches(v.Value) .Cast() .Select(x => x.Value) .Distinct() .ToArray(); var matchCount = matches.Length; - var isMultiMatch = matchCount > 1 || (matchCount == 1 && v.InnerText != $"{{{{{matches[0]}}}}}"); + var isMultiMatch = matchCount > 1 || (matchCount == 1 && v.Value != $"{{{{{matches[0]}}}}}"); foreach (var formatText in matches) { xRowInfo.FormatText = formatText; - var mapNames = formatText.Split('.'); - if (mapNames[0].StartsWith("$")) //e.g:"$rowindex" it doesn't need to check cell value type + var propNames = formatText.Split('.'); + if (propNames[0].StartsWith("$")) //e.g:"$rowindex" it doesn't need to check cell value type continue; // TODO: default if not contain property key, clean the template string - if (!inputMaps.TryGetValue(mapNames[0], out var cellValue)) + if (!inputMaps.TryGetValue(propNames[0], out var cellValue)) { if (!_configuration.IgnoreTemplateParameterMissing) - throw new KeyNotFoundException($"The parameter '{mapNames[0]}' was not found."); + throw new KeyNotFoundException($"The parameter '{propNames[0]}' was not found."); - v.InnerText = v.InnerText.Replace($"{{{{{mapNames[0]}}}}}", ""); + v?.Value = v.Value.Replace($"{{{{{propNames[0]}}}}}", ""); break; } @@ -1200,13 +1170,13 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap xRowInfo.CellIEnumerableValuesCount++; if (xRowInfo.IEnumerableGenericType is null && element is not null) { - xRowInfo.IEnumerablePropName = mapNames[0]; + xRowInfo.IEnumerablePropName = propNames[0]; xRowInfo.IEnumerableGenericType = element.GetType(); - if (element is IDictionary dic) + if (element is IDictionary dic) { xRowInfo.IsDictionary = true; - xRowInfo.MembersMap = dic.ToDictionary( + xRowInfo.PropsMap = dic.ToDictionary( kv => kv.Key, kv => kv.Value is not null ? new MemberInfo { UnderlyingMemberType = Nullable.GetUnderlyingType(kv.Value.GetType()) ?? kv.Value.GetType() } @@ -1229,17 +1199,17 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap { if (!values.ContainsKey(f.Name)) { - var fieldInfo = new MemberInfo + var propInfo = new MemberInfo { FieldInfo = f, PropertyInfoOrFieldInfo = PropertyInfoOrFieldInfo.FieldInfo, UnderlyingMemberType = Nullable.GetUnderlyingType(f.FieldType) ?? f.FieldType }; - values.Add(f.Name, fieldInfo); + values.Add(f.Name, propInfo); } } - xRowInfo.MembersMap = values; + xRowInfo.PropsMap = values; } } @@ -1254,37 +1224,34 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap //only check first one match IEnumerable, so only render one collection at same row // Empty collection parameter will get exception https://gitee.com/dotnetchina/MiniExcel/issues/I4WM67 - if (xRowInfo.MembersMap is null) + if (xRowInfo.PropsMap is null) { - v.InnerText = v.InnerText.Replace($"{{{{{mapNames[0]}}}}}", mapNames[1]); + v.Value = v.Value.Replace($"{{{{{propNames[0]}}}}}", propNames[1]); break; } - if (!xRowInfo.MembersMap.TryGetValue(mapNames[1], out var map)) + if (!xRowInfo.PropsMap.TryGetValue(propNames[1], out var prop)) { - v.InnerText = v.InnerText.Replace($"{{{{{mapNames[0]}.{mapNames[1]}}}}}", ""); + v?.Value = v.Value.Replace($"{{{{{propNames[0]}.{propNames[1]}}}}}", ""); continue; - - //why unreachable exception? - throw new InvalidDataException($"{mapNames[0]} doesn't have {mapNames[1]} property"); } // auto check type https://github.com/mini-software/MiniExcel/issues/177 - var type = map.UnderlyingMemberType; //avoid nullable + var type = prop.UnderlyingMemberType; //avoid nullable if (isMultiMatch) { - SetCellType(c, "str"); + SetCellType(cell, "str"); } else if (TypeHelper.IsNumericType(type) && !type.IsEnum) { - SetCellType(c, "n"); + SetCellType(cell, "n"); } else if (Type.GetTypeCode(type) == TypeCode.Boolean) { - SetCellType(c, "b"); + SetCellType(cell, "b"); } else if (Type.GetTypeCode(type) == TypeCode.DateTime) { - SetCellType(c, "str"); + SetCellType(cell, "str"); } break; @@ -1293,7 +1260,7 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap { if (xRowInfo.CellIEnumerableValues is null) { - xRowInfo.IEnumerablePropName = mapNames[0]; + xRowInfo.IEnumerablePropName = propNames[0]; xRowInfo.IEnumerableGenericType = typeof(DataRow); xRowInfo.IsDataTable = true; @@ -1311,32 +1278,32 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap } //TODO:need to optimize //maxRowIndexDiff = dt.Rows.Count <= 1 ? 0 : dt.Rows.Count-1; - xRowInfo.MembersMap = dt.Columns.Cast().ToDictionary(col => + xRowInfo.PropsMap = dt.Columns.Cast().ToDictionary(col => col.ColumnName, col => new MemberInfo { UnderlyingMemberType = Nullable.GetUnderlyingType(col.DataType) } ); } - var column = dt.Columns[mapNames[1]]; + var column = dt.Columns[propNames[1]]; var type = Nullable.GetUnderlyingType(column.DataType) ?? column.DataType; //avoid nullable - if (!xRowInfo.MembersMap.ContainsKey(mapNames[1])) - throw new InvalidDataException($"{mapNames[0]} doesn't have {mapNames[1]} property"); + if (!xRowInfo.PropsMap.ContainsKey(propNames[1])) + throw new InvalidDataException($"{propNames[0]} doesn't have {propNames[1]} property"); if (isMultiMatch) { - SetCellType(c, "str"); + SetCellType(cell, "str"); } else if (TypeHelper.IsNumericType(type) && !type.IsEnum) { - SetCellType(c, "n"); + SetCellType(cell, "n"); } else if (Type.GetTypeCode(type) == TypeCode.Boolean) { - SetCellType(c, "b"); + SetCellType(cell, "b"); } else if (Type.GetTypeCode(type) == TypeCode.DateTime) { - SetCellType(c, "str"); + SetCellType(cell, "str"); } } else @@ -1344,16 +1311,16 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap var cellValueStr = cellValue?.ToString(); // value did encodexml, so don't duplicate encode value (https://gitee.com/dotnetchina/MiniExcel/issues/I4DQUN) if (isMultiMatch || cellValue is string) // if matchs count over 1 need to set type=str (https://user-images.githubusercontent.com/12729184/114530109-39d46d00-9c7d-11eb-8f6b-52ad8600aca3.png) { - SetCellType(c, "str"); + SetCellType(cell, "str"); } else if (decimal.TryParse(cellValueStr, out var outV)) { - SetCellType(c, "n"); + SetCellType(cell, "n"); cellValueStr = outV.ToString(CultureInfo.InvariantCulture); } else if (cellValue is bool b) { - SetCellType(c, "b"); + SetCellType(cell, "b"); cellValueStr = b ? "1" : "0"; } else if (cellValue is DateTime timestamp) @@ -1362,14 +1329,14 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap cellValueStr = timestamp.ToString("yyyy-MM-dd HH:mm:ss"); } - if (string.IsNullOrEmpty(cellValueStr) && string.IsNullOrEmpty(c.GetAttribute("t"))) + if (string.IsNullOrEmpty(cellValueStr) && string.IsNullOrEmpty(cell.Attribute("t")?.Value)) { - SetCellType(c, "str"); + SetCellType(cell, "str"); } // Re-acquire v after SetCellType may have changed DOM structure - v = c.SelectSingleNode("x:v", Ns) ?? c.SelectSingleNode("x:is/x:t", Ns); - v.InnerText = v.InnerText.Replace($"{{{{{mapNames[0]}}}}}", cellValueStr); //TODO: auto check type and set value + v = cell.Element(SpreadsheetNs + "v") ?? cell.Element(SpreadsheetNs + "is")?.Element(SpreadsheetNs + "t"); + v?.SetValue(v.Value.Replace($"{{{{{propNames[0]}}}}}", cellValueStr)); //TODO: auto check type and set value } } //if (xRowInfo.CellIEnumerableValues is not null) //2. From left to right, only the first set is used as the basis for the list @@ -1382,17 +1349,17 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap { var letter = StringHelper.GetLetters(refs[1]); var digit = StringHelper.GetNumber(refs[1]); - dimension.SetAttribute("ref", $"{refs[0]}:{letter}{digit + maxRowIndexDiff}"); + dimension.SetAttributeValue("ref", $"{refs[0]}:{letter}{digit + maxRowIndexDiff}"); } else { var letter = StringHelper.GetLetters(refs[0]); var digit = StringHelper.GetNumber(refs[0]); - dimension.SetAttribute("ref", $"A1:{letter}{digit + maxRowIndexDiff}"); + dimension.SetAttributeValue("ref", $"A1:{letter}{digit + maxRowIndexDiff}"); } } - private static bool EvaluateStatement(object tagValue, string comparisonOperator, string value) + private static bool EvaluateStatement(object? tagValue, string comparisonOperator, string value) { return tagValue switch { @@ -1403,7 +1370,8 @@ private static bool EvaluateStatement(object tagValue, string comparisonOperator ">" => dtg > doubleNumber, "<" => dtg < doubleNumber, ">=" => dtg >= doubleNumber, - "<=" => dtg <= doubleNumber + "<=" => dtg <= doubleNumber, + _ => throw new InvalidDataException($"Invalid comparison oeprator: {comparisonOperator}") }, int itg when int.TryParse(value, out var intNumber) => comparisonOperator switch @@ -1413,7 +1381,8 @@ private static bool EvaluateStatement(object tagValue, string comparisonOperator ">" => itg > intNumber, "<" => itg < intNumber, ">=" => itg >= intNumber, - "<=" => itg <= intNumber + "<=" => itg <= intNumber, + _ => throw new InvalidDataException($"Invalid comparison oeprator: {comparisonOperator}") }, DateTime dttg when DateTime.TryParse(value, out var date) => comparisonOperator switch @@ -1423,13 +1392,15 @@ private static bool EvaluateStatement(object tagValue, string comparisonOperator ">" => dttg > date, "<" => dttg < date, ">=" => dttg >= date, - "<=" => dttg <= date + "<=" => dttg <= date, + _ => throw new InvalidDataException($"Invalid comparison oeprator: {comparisonOperator}") }, string stg => comparisonOperator switch { "==" => stg == value, - "!=" => stg != value + "!=" => stg != value, + _ => throw new InvalidDataException($"Invalid comparison oeprator: {comparisonOperator}") }, _ => false diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.MergeCells.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.MergeCells.cs index 41d454ec..b5c7aa11 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.MergeCells.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.MergeCells.cs @@ -30,13 +30,14 @@ public async Task MergeSameCellsAsync(byte[] fileInBytes, CancellationToken canc private async Task MergeSameCellsImplAsync(Stream stream, CancellationToken cancellationToken = default) { await stream.CopyToAsync(_outputFileStream -#if NETCOREAPP2_1_OR_GREATER +#if NET8_0_OR_GREATER , cancellationToken #endif ).ConfigureAwait(false); using var reader = await OpenXmlReader.CreateAsync(_outputFileStream, null, cancellationToken: cancellationToken).ConfigureAwait(false); - using var archive = new OpenXmlZip(_outputFileStream, mode: ZipArchiveMode.Update, true, Encoding.UTF8); + var archive = await OpenXmlZip.CreateAsync(_outputFileStream, mode: ZipArchiveMode.Update, true, Encoding.UTF8, true, cancellationToken).ConfigureAwait(false); + await using var disposableArchive = archive.ConfigureAwait(false); //read sharedString var sharedStrings = reader.SharedStrings; @@ -49,35 +50,30 @@ await stream.CopyToAsync(_outputFileStream foreach (var sheet in sheets) { - _xRowInfos = []; //every time need to use new XRowInfos or it'll cause duplicate problem: https://user-images.githubusercontent.com/12729184/115003101-0fcab700-9ed8-11eb-9151-ca4d7b86d59e.png - _xMergeCellInfos = []; - _newXMergeCellInfos = []; + // XRowInfos musy be cleared for every sheet or it'll cause duplicates + _xRowInfos.Clear(); + _xMergeCellInfos.Clear(); + _newXMergeCellInfos.Clear(); + _calcChainCellRefs.Clear(); -#if NETSTANDARD2_0 - using var sheetStream = sheet.Open(); -#else -#if NET10_0_OR_GREATER - var sheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false); -#else - var sheetStream = sheet.Open(); -#endif - await using var disposableSheetStream = sheetStream.ConfigureAwait(false); -#endif - var fullName = sheet.FullName; + var entry = archive.ZipFile.CreateEntry(sheet.FullName); - var entry = archive.ZipFile.CreateEntry(fullName); -#if NETSTANDARD2_0 - using var zipStream = entry.Open(); -#else +#if NET8_0_OR_GREATER #if NET10_0_OR_GREATER + var sheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false); var zipStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); #else + var sheetStream = sheet.Open(); var zipStream = entry.Open(); #endif + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); await using var disposableZipStream = zipStream.ConfigureAwait(false); +#else + using var sheetStream = sheet.Open(); + using var zipStream = entry.Open(); #endif - await GenerateSheetXmlImplByUpdateModeAsync(sheet, zipStream, sheetStream, new Dictionary(), sharedStrings, mergeCells: true, cancellationToken).ConfigureAwait(false); - //doc.Save(zipStream); //don't do it beacause: https://user-images.githubusercontent.com/12729184/114361127-61a5d100-9ba8-11eb-9bb9-34f076ee28a2.png + + await GenerateSheetByUpdateModeAsync(sheet, zipStream, sheetStream, new Dictionary(), sharedStrings, mergeCells: true, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs index 2edceeae..56c33cf4 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -1,31 +1,15 @@ using MiniExcelLib.Core; -using MiniExcelLib.OpenXml.Constants; using CalcChainHelper = MiniExcelLib.OpenXml.Utils.CalcChainHelper; namespace MiniExcelLib.OpenXml.Templates; internal partial class OpenXmlTemplate : IMiniExcelTemplate { -#if NET7_0_OR_GREATER - [GeneratedRegex("(?<={{).*?(?=}})")] private static partial Regex ExpressionRegex(); - private static readonly Regex IsExpressionRegex = ExpressionRegex(); -#else - private static readonly Regex IsExpressionRegex = new("(?<={{).*?(?=}})"); -#endif - private static readonly XmlNamespaceManager Ns; - private readonly Stream _outputFileStream; private readonly OpenXmlConfiguration _configuration; - private readonly IInputValueExtractor _inputValueExtractor; + private readonly OpenXmlValueExtractor _inputValueExtractor; private readonly StringBuilder _calcChainContent = new(); - static OpenXmlTemplate() - { - Ns = new XmlNamespaceManager(new NameTable()); - Ns.AddNamespace("x", Schemas.SpreadsheetmlXmlNs); - Ns.AddNamespace("x14ac", Schemas.SpreadsheetmlXmlX14Ac); - } - internal OpenXmlTemplate(Stream stream, IMiniExcelConfiguration? configuration, OpenXmlValueExtractor inputValueExtractor) { _outputFileStream = stream; @@ -36,14 +20,24 @@ internal OpenXmlTemplate(Stream stream, IMiniExcelConfiguration? configuration, [CreateSyncVersion] public async Task SaveAsByTemplateAsync(string templatePath, object value, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = File.Open(templatePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = File.Open(templatePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); +#endif await SaveAsByTemplateAsync(stream, value, cancellationToken).ConfigureAwait(false); } [CreateSyncVersion] public async Task SaveAsByTemplateAsync(byte[] templateBytes, object value, CancellationToken cancellationToken = default) { - using Stream stream = new MemoryStream(templateBytes); +#if NET8_0_OR_GREATER + var stream = new MemoryStream(templateBytes); + await using var disposableStream = stream.ConfigureAwait(false); +#else + using var stream = new MemoryStream(templateBytes); +#endif await SaveAsByTemplateAsync(stream, value, cancellationToken).ConfigureAwait(false); } @@ -54,8 +48,9 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can throw new ArgumentException("The template stream must be seekable"); templateStream.Seek(0, SeekOrigin.Begin); - using var templateReader = await MiniExcelLib.OpenXml.OpenXmlReader.CreateAsync(templateStream, null, cancellationToken: cancellationToken).ConfigureAwait(false); - using var outputFileArchive = new OpenXmlZip(_outputFileStream, mode: ZipArchiveMode.Create, true, Encoding.UTF8, isUpdateMode: false); + using var templateReader = await OpenXmlReader.CreateAsync(templateStream, null, cancellationToken: cancellationToken).ConfigureAwait(false); + var outputFileArchive = await OpenXmlZip.CreateAsync(_outputFileStream, mode: ZipArchiveMode.Create, true, Encoding.UTF8, isUpdateMode: false, cancellationToken: cancellationToken).ConfigureAwait(false); + await using var disposableOutputFileArchive = outputFileArchive.ConfigureAwait(false); try { @@ -72,39 +67,43 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can outputFileArchive.Entries.Add(entry.FullName.Replace('\\', '/'), entry); } + // Create a new zip file for writing templateStream.Position = 0; +#if NET10_0_OR_GREATER + var originalArchive = await ZipArchive.CreateAsync(templateStream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false); + await using var disposableArchive = originalArchive.ConfigureAwait(false); +#else using var originalArchive = new ZipArchive(templateStream, ZipArchiveMode.Read); - // Create a new zip file for writing +#endif // Iterate through each entry in the original archive foreach (var entry in originalArchive.Entries) { - if (entry.FullName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || - entry.FullName.StartsWith("/xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || - entry.FullName.Contains("xl/calcChain.xml") - ) + var entryName = entry.FullName.TrimStart('/'); + if (entryName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || entryName.Equals("xl/calcChain.xml")) continue; // Create a new entry in the new archive with the same name var newEntry = outputFileArchive.ZipFile.CreateEntry(entry.FullName); // Copy the content of the original entry to the new entry +#if NET8_0_OR_GREATER #if NET10_0_OR_GREATER var originalEntryStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - await using var disposableEntryStream = originalEntryStream.ConfigureAwait(false); + var newEntryStream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false); #else - using var originalEntryStream = entry.Open(); + var originalEntryStream = entry.Open(); + var newEntryStream = newEntry.Open(); #endif - // Copy the content of the original entry to the new entry -#if NET10_0_OR_GREATER - var newEntryStream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableEntryStream = originalEntryStream.ConfigureAwait(false); await using var disposableNewEntryStream = newEntryStream.ConfigureAwait(false); #else + using var originalEntryStream = entry.Open(); using var newEntryStream = newEntry.Open(); #endif await originalEntryStream.CopyToAsync(newEntryStream -#if NETCOREAPP2_1_OR_GREATER +#if NET8_0_OR_GREATER , cancellationToken #endif ).ConfigureAwait(false); @@ -116,35 +115,35 @@ await originalEntryStream.CopyToAsync(newEntryStream //read all xlsx sheets var templateSheets = templateReader.Archive.ZipFile.Entries - .Where(entry => - entry.FullName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || - entry.FullName.StartsWith("/xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase)); + .Where(entry => entry.FullName + .TrimStart('/') + .StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase)); int sheetIdx = 0; foreach (var templateSheet in templateSheets) { - //every time need to use new XRowInfos or it'll cause duplicate problem: https://user-images.githubusercontent.com/12729184/115003101-0fcab700-9ed8-11eb-9151-ca4d7b86d59e.png - _xRowInfos = []; - _xMergeCellInfos = []; - _newXMergeCellInfos = []; - -#if NET10_0_OR_GREATER - var templateSheetStream = await templateSheet.OpenAsync(cancellationToken).ConfigureAwait(false); -#else - var templateSheetStream = templateSheet.Open(); -#endif + // XRowInfos musy be cleared for every sheet or it'll cause duplicates: https://user-images.githubusercontent.com/12729184/115003101-0fcab700-9ed8-11eb-9151-ca4d7b86d59e.png + _xRowInfos.Clear(); + _xMergeCellInfos.Clear(); + _newXMergeCellInfos.Clear(); + _calcChainCellRefs.Clear(); + var templateFullName = templateSheet.FullName; - var inputValues = _inputValueExtractor.ToValueDictionary(value); var outputZipEntry = outputFileArchive.ZipFile.CreateEntry(templateFullName); +#if NET8_0_OR_GREATER #if NET10_0_OR_GREATER var outputZipSheetEntryStream = await outputZipEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#else + var outputZipSheetEntryStream = outputZipEntry.Open(); +#endif await using var disposableSheetEntryStream = outputZipSheetEntryStream.ConfigureAwait(false); #else using var outputZipSheetEntryStream = outputZipEntry.Open(); #endif - await GenerateSheetXmlImplByCreateModeAsync(templateSheet, outputZipSheetEntryStream, inputValues, templateSharedStrings).ConfigureAwait(false); + await GenerateSheetByCreateModeAsync(templateSheet, outputZipSheetEntryStream, inputValues, templateSharedStrings, cancellationToken: cancellationToken).ConfigureAwait(false); + //doc.Save(zipStream); //don't do it because: https://user-images.githubusercontent.com/12729184/114361127-61a5d100-9ba8-11eb-9bb9-34f076ee28a2.png // disposing writer disposes streams as well. read and parse calc functions before that @@ -160,8 +159,12 @@ await originalEntryStream.CopyToAsync(newEntryStream //calcChain.Delete(); var calcChainEntry = outputFileArchive.ZipFile.CreateEntry(calcChainPathName); +#if NET8_0_OR_GREATER #if NET10_0_OR_GREATER var calcChainStream = await calcChainEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#else + var calcChainStream = calcChainEntry.Open(); +#endif await using var disposableChainEntryStream = calcChainStream.ConfigureAwait(false); #else using var calcChainStream = calcChainEntry.Open(); @@ -177,21 +180,23 @@ await originalEntryStream.CopyToAsync(newEntryStream var newEntry = outputFileArchive.ZipFile.CreateEntry(entry.FullName); // Copy the content of the original entry to the new entry +#if NET8_0_OR_GREATER #if NET10_0_OR_GREATER var originalEntryStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - await using var disposableEntryStream = originalEntryStream.ConfigureAwait(false); + var newEntryStream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false); #else - using var originalEntryStream = entry.Open(); + var originalEntryStream = entry.Open(); + var newEntryStream = newEntry.Open(); #endif -#if NET10_0_OR_GREATER - var newEntryStream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false); - await using var disposableNewEntryStream = newEntryStream.ConfigureAwait(false); + await using var disposableEntryStream = originalEntryStream.ConfigureAwait(false); + await using var disposableNewEntryStream = newEntryStream.ConfigureAwait(false); #else + using var originalEntryStream = entry.Open(); using var newEntryStream = newEntry.Open(); #endif await originalEntryStream.CopyToAsync(newEntryStream -#if NETCOREAPP2_1_OR_GREATER +#if NET8_0_OR_GREATER , cancellationToken #endif ).ConfigureAwait(false); diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplateUtils.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplateUtils.cs index 608269af..b0ffcc62 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplateUtils.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplateUtils.cs @@ -1,12 +1,14 @@ -namespace MiniExcelLib.OpenXml.Templates; +using System.Xml.Linq; + +namespace MiniExcelLib.OpenXml.Templates; internal class XRowInfo { public string FormatText { get; set; } public string IEnumerablePropName { get; set; } - public XmlElement Row { get; set; } + public XElement Row { get; set; } public Type IEnumerableGenericType { get; set; } - public IDictionary MembersMap { get; set; } + public IDictionary PropsMap { get; set; } public bool IsDictionary { get; set; } public bool IsDataTable { get; set; } public int CellIEnumerableValuesCount { get; set; } @@ -14,22 +16,7 @@ internal class XRowInfo public IEnumerable? CellIEnumerableValues { get; set; } public XMergeCell? IEnumerableMercell { get; set; } public List? RowMercells { get; set; } - public List? ConditionalFormats { get; set; } -} - -internal class MemberInfo -{ - public PropertyInfo PropertyInfo { get; set; } - public FieldInfo FieldInfo { get; set; } - public Type UnderlyingMemberType { get; set; } - public PropertyInfoOrFieldInfo PropertyInfoOrFieldInfo { get; set; } = PropertyInfoOrFieldInfo.None; -} - -internal enum PropertyInfoOrFieldInfo -{ - None = 0, - PropertyInfo = 1, - FieldInfo = 2 + public List? ConditionalFormats { get; set; } } internal class XMergeCell @@ -45,11 +32,12 @@ public XMergeCell(XMergeCell mergeCell) MergeCell = mergeCell.MergeCell; } - public XMergeCell(XmlElement mergeCell) + public XMergeCell(XElement mergeCell) { - var refAttr = mergeCell.Attributes["ref"].Value; - var refs = refAttr.Split(':'); + var refAttr = mergeCell.Attribute("ref")?.Value; + var refs = refAttr?.Split(':'); + //TODO: width,height var xy1 = refs[0]; X1 = CellReferenceConverter.GetNumericalIndex(StringHelper.GetLetters(refs[0])); Y1 = StringHelper.GetNumber(xy1); @@ -81,20 +69,14 @@ public XMergeCell(string x1, int y1, string x2, int y2) public int X2 { get; set; } public int Y2 { get; set; } public string Ref => $"{CellReferenceConverter.GetAlphabeticalIndex(X1)}{Y1}:{CellReferenceConverter.GetAlphabeticalIndex(X2)}{Y2}"; - public XmlElement MergeCell { get; set; } + public XElement MergeCell { get; set; } public int Width { get; internal set; } public int Height { get; internal set; } - public string ToXmlString(string prefix) + public string ToXmlString(string? prefix) => $"<{prefix}mergeCell ref=\"{CellReferenceConverter.GetAlphabeticalIndex(X1)}{Y1}:{CellReferenceConverter.GetAlphabeticalIndex(X2)}{Y2}\"/>"; } -internal class MergeCellIndex(int rowStart, int rowEnd) -{ - public int RowStart { get; } = rowStart; - public int RowEnd { get; } = rowEnd; -} - internal class XChildNode { public string? InnerText { get; set; } @@ -102,24 +84,21 @@ internal class XChildNode public int RowIndex { get; set; } } -internal struct Range +internal class MergeCellIndex(int rowStart, int rowEnd) { - public int StartColumn { get; set; } - public int StartRow { get; set; } - public int EndColumn { get; set; } - public int EndRow { get; set; } - - public bool ContainsRow(int row) => StartRow <= row && row <= EndRow; + public int RowStart { get; } = rowStart; + public int RowEnd { get; } = rowEnd; } -internal class ConditionalFormatRange + +internal class MemberInfo { - public XmlNode? Node { get; set; } - public List Ranges { get; set; } = []; + public PropertyInfo PropertyInfo { get; set; } + public FieldInfo FieldInfo { get; set; } + public Type UnderlyingMemberType { get; set; } + public PropertyInfoOrFieldInfo PropertyInfoOrFieldInfo { get; set; } = PropertyInfoOrFieldInfo.None; } -internal enum SpecialCellType { None, Group, Endgroup, Merge, Header } - internal class GenerateCellValuesContext { public int RowIndexDiff { get; set; } @@ -130,3 +109,23 @@ internal class GenerateCellValuesContext public bool IsFirst { get; set; } public int EnumerableIndex { get; set; } } + +internal class ConditionalFormatRange +{ + public XElement? Node { get; set; } + public List Ranges { get; set; } = []; +} + +internal struct Range +{ + public int StartColumn { get; set; } + public int StartRow { get; set; } + public int EndColumn { get; set; } + public int EndRow { get; set; } + + public bool ContainsRow(int row) => StartRow <= row && row <= EndRow; +} + +internal enum PropertyInfoOrFieldInfo { None, PropertyInfo, FieldInfo } + +internal enum SpecialCellType { None, Group, Endgroup, Merge, Header } diff --git a/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs b/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs index c0d9c826..6b8f8c41 100644 --- a/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs +++ b/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs @@ -4,7 +4,7 @@ namespace MiniExcelLib.OpenXml.Utils; internal static partial class XmlReaderHelper { - private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlNs, Schemas.SpreadsheetmlXmlStrictNs]; + private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlMain, Schemas.SpreadsheetmlXmlStrictNs]; /// /// Pass <?xml> and <worksheet> diff --git a/src/MiniExcel.OpenXml/Zip/OpenXmlZip.cs b/src/MiniExcel.OpenXml/Zip/OpenXmlZip.cs index 0d3c845b..2e1a68c4 100644 --- a/src/MiniExcel.OpenXml/Zip/OpenXmlZip.cs +++ b/src/MiniExcel.OpenXml/Zip/OpenXmlZip.cs @@ -3,79 +3,81 @@ namespace MiniExcelLib.OpenXml.Zip; /// Copy & modified by ExcelDataReader ZipWorker @MIT License -internal class OpenXmlZip : IDisposable +internal sealed partial class OpenXmlZip : IDisposable, IAsyncDisposable { - private bool _disposed; - - public ReadOnlyCollection EntryCollection = new([]); - - internal readonly Dictionary Entries; - internal ZipArchive? ZipFile; - private static readonly XmlReaderSettings XmlSettings = new() { IgnoreComments = true, IgnoreWhitespace = true, XmlResolver = null, }; + + private bool _disposed; + + internal ZipArchive ZipFile { get; } + internal Dictionary Entries { get; } + internal ReadOnlyCollection EntryCollection { get; set; } = new([]); - public OpenXmlZip(Stream fileStream, ZipArchiveMode mode = ZipArchiveMode.Read, bool leaveOpen = false, Encoding? entryNameEncoding = null, bool isUpdateMode = true) + + private OpenXmlZip(ZipArchive zipArchive, Dictionary entries) + { + ZipFile = zipArchive; + Entries = entries; + } + + // todo: convert to ValueTask and create auxiliary methods to avoid generation of async state machine for framework versions lower than .NET 10 + [CreateSyncVersion] + internal static async Task CreateAsync(Stream fileStream, ZipArchiveMode mode = ZipArchiveMode.Read, bool leaveOpen = false, Encoding? entryNameEncoding = null, bool isUpdateMode = true, CancellationToken cancellationToken = default) { entryNameEncoding ??= Encoding.UTF8; - ZipFile = new ZipArchive(fileStream, mode, leaveOpen, entryNameEncoding); - Entries = new Dictionary(StringComparer.OrdinalIgnoreCase); - +#if NET10_0_OR_GREATER + var zipFile = await ZipArchive.CreateAsync(fileStream, mode, leaveOpen, entryNameEncoding, cancellationToken).ConfigureAwait(false); +#else + var zipFile = new ZipArchive(fileStream, mode, leaveOpen, entryNameEncoding); +#endif + if (!isUpdateMode) - return; - + return new OpenXmlZip(zipFile, []); + try { - EntryCollection = ZipFile.Entries; //TODO:need to remove - } - catch (InvalidDataException e) - { - throw new InvalidDataException($"The file doesn't contain valid OpenXml, please check the project issues or open one. {e.Message}"); + var entries = zipFile.Entries.ToDictionary(entry => entry.FullName.Replace('\\', '/'), entry => entry, StringComparer.OrdinalIgnoreCase); + return new OpenXmlZip(zipFile, entries) + { + EntryCollection = zipFile.Entries + }; } - - foreach (var entry in ZipFile.Entries) + catch (InvalidDataException ex) { - Entries.Add(entry.FullName.Replace('\\', '/'), entry); + throw new InvalidDataException("The file doesn't contain valid OpenXml data.", ex); } } public ZipArchiveEntry? GetEntry(string path) => Entries.GetValueOrDefault(path); - public XmlReader? GetXmlReader(string path) - { - var entry = GetEntry(path); - return entry is not null ? XmlReader.Create(entry.Open(), XmlSettings) : null; - } + public XmlReader? GetXmlReader(string path) => GetEntry(path) is { } entry + ? XmlReader.Create(entry.Open(), XmlSettings) + : null; - ~OpenXmlZip() - { - Dispose(false); - } public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); + if (!_disposed) + { + ZipFile.Dispose(); + _disposed = true; + } } - protected virtual void Dispose(bool disposing) + public async ValueTask DisposeAsync() { - // Check to see if Dispose has already been called. if (!_disposed) { - if (disposing) - { - if (ZipFile is not null) - { - ZipFile.Dispose(); - ZipFile = null; - } - } - +#if NET10_0_OR_GREATER + await ZipFile.DisposeAsync().ConfigureAwait(false); +#else + ZipFile.Dispose(); +#endif _disposed = true; } } diff --git a/tests/MiniExcel.OpenXml.Tests/MiniExcelIssueAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/MiniExcelIssueAsyncTests.cs index 5ea5e072..3c11664d 100644 --- a/tests/MiniExcel.OpenXml.Tests/MiniExcelIssueAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/MiniExcelIssueAsyncTests.cs @@ -952,10 +952,9 @@ public async Task Issue193() }; await _excelTemplater.FillTemplateAsync(path, templatePath, value); - foreach (var sheetName in _excelImporter.GetSheetNames(path)) + foreach (var sheetName in await _excelImporter.GetSheetNamesAsync(path)) { - var q = _excelImporter.QueryAsync(path, sheetName: sheetName).ToBlockingEnumerable(); - var rows = q.ToList(); + var rows = await _excelImporter.QueryAsync(path, sheetName: sheetName).ToListAsync(); Assert.Equal(9, rows.Count); Assert.Equal("FooCompany", rows[0].A); @@ -979,7 +978,6 @@ public async Task Issue193() //TODO:row can't contain xmlns //![image](https://user-images.githubusercontent.com/12729184/114998840-ead44500-9ed3-11eb-8611-58afb98faed9.png) - } } diff --git a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateAsyncTests.cs index 1c6d7250..535e858a 100644 --- a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateAsyncTests.cs @@ -31,7 +31,7 @@ public async Task DatatableTemptyRowTest() ["employees"] = employees }; await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); var dimension = SheetHelper.GetFirstSheetDimensionRefValue(path.ToString()); Assert.Equal("A1:C5", dimension); @@ -56,7 +56,7 @@ public async Task DatatableTemptyRowTest() }; await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); var dimension = SheetHelper.GetFirstSheetDimensionRefValue(path.ToString()); Assert.Equal("A1:C5", dimension); @@ -90,7 +90,7 @@ public async Task DatatableTest() ["employees"] = employees }; await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); var dimension = SheetHelper.GetFirstSheetDimensionRefValue(path.ToString()); Assert.Equal("A1:C9", dimension); @@ -113,7 +113,7 @@ public async Task DatatableTest() Assert.Equal("IT", rows[8].C); { - rows = _excelImporter.QueryAsync(path.ToString(), sheetName: "Sheet2").ToBlockingEnumerable().ToList(); + rows = await _excelImporter.QueryAsync(path.ToString(), sheetName: "Sheet2").ToListAsync(); Assert.Equal(9, rows.Count); Assert.Equal("FooCompany", rows[0].A); @@ -153,7 +153,7 @@ public async Task DapperTemplateTest() await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); { - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); Assert.Equal(9, rows.Count); @@ -178,7 +178,7 @@ public async Task DapperTemplateTest() } { - var rows = _excelImporter.QueryAsync(path.ToString(), sheetName: "Sheet2").ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString(), sheetName: "Sheet2").ToListAsync(); Assert.Equal(9, rows.Count); Assert.Equal("FooCompany", rows[0].A); @@ -297,7 +297,7 @@ public async Task TestGithubProject() }; await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); Assert.Equal("ITWeiHan Github Projects", rows[0].B); Assert.Equal("Total Star : 178", rows[8].C); @@ -353,30 +353,17 @@ public async Task TestIEnumerableType() Assert.Equal(poco.datetime, rows[0].datetime); Assert.Equal(poco.Guid, rows[0].Guid); - Assert.Null(rows[1].@string); - Assert.Null(rows[1].@int); - Assert.Null(rows[1].@double); - Assert.Null(rows[1].@decimal); - Assert.Null(rows[1].@bool); - Assert.Null(rows[1].datetime); - Assert.Null(rows[1].Guid); - // special input null but query is empty vo - Assert.Null(rows[2].@string); - Assert.Null(rows[2].@int); - Assert.Null(rows[2].@double); - Assert.Null(rows[2].@decimal); - Assert.Null(rows[2].@bool); - Assert.Null(rows[2].datetime); - Assert.Null(rows[2].Guid); - - Assert.Null(rows[3].@string); - Assert.Null(rows[3].@int); - Assert.Null(rows[3].@double); - Assert.Null(rows[3].@decimal); - Assert.Null(rows[3].@bool); - Assert.Null(rows[3].datetime); - Assert.Null(rows[3].Guid); + for (int i = 1; i <= 3; i++) + { + Assert.Null(rows[i].@string); + Assert.Null(rows[i].@int); + Assert.Null(rows[i].@double); + Assert.Null(rows[i].@decimal); + Assert.Null(rows[i].@bool); + Assert.Null(rows[i].datetime); + Assert.Null(rows[i].Guid); + } Assert.Equal(poco.@string, rows[4].@string); Assert.Equal(poco.@int, rows[4].@int); @@ -434,7 +421,7 @@ public async Task TemplateCenterEmptyTest() } [Fact] - public async Task TemplateAsyncBasiTest() + public async Task TemplateAsyncBasicTest() { var templatePath = PathHelper.GetFile("xlsx/TestTemplateEasyFill.xlsx"); { @@ -450,7 +437,7 @@ public async Task TemplateAsyncBasiTest() }; await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); Assert.Equal("Jack", rows[1].A); Assert.Equal("2021-01-01 00:00:00", rows[1].B); Assert.Equal(true, rows[1].C); @@ -474,7 +461,7 @@ public async Task TemplateAsyncBasiTest() }; await _excelTemplater.FillTemplateAsync(path, templateBytes, value); - var rows = (_excelImporter.QueryAsync(path).ToBlockingEnumerable()).ToList(); + var rows = await _excelImporter.QueryAsync(path).ToListAsync(); Assert.Equal("Jack", rows[1].A); Assert.Equal("2021-01-01 00:00:00", rows[1].B); Assert.Equal(true, rows[1].C); @@ -502,7 +489,7 @@ public async Task TemplateAsyncBasiTest() await _excelTemplater.FillTemplateAsync(stream, templateBytes, value); } - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); Assert.Equal("Jack", rows[1].A); Assert.Equal("2021-01-01 00:00:00", rows[1].B); Assert.Equal(true, rows[1].C); @@ -526,7 +513,7 @@ public async Task TemplateAsyncBasiTest() }; await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); Assert.Equal("Jack", rows[1].A); Assert.Equal("2021-01-01 00:00:00", rows[1].B); Assert.Equal(true, rows[1].C); @@ -787,7 +774,7 @@ public async Task TemplateTest() await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); { - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); Assert.Equal(9, rows.Count); Assert.Equal("FooCompany", rows[0].A); @@ -811,7 +798,7 @@ public async Task TemplateTest() } { - var rows = _excelImporter.QueryAsync(path.ToString(), sheetName: "Sheet2").ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString(), sheetName: "Sheet2").ToListAsync(); Assert.Equal(9, rows.Count); Assert.Equal("FooCompany", rows[0].A); @@ -857,7 +844,7 @@ public async Task TemplateTest() }; await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value); - var rows = _excelImporter.QueryAsync(path.ToString()).ToBlockingEnumerable().ToList(); + var rows = await _excelImporter.QueryAsync(path.ToString()).ToListAsync(); Assert.Equal("FooCompany", rows[0].A); Assert.Equal("Jack", rows[2].B); Assert.Equal("HR", rows[2].C); @@ -900,4 +887,58 @@ await Assert.ThrowsAsync(async () => await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value, cancellationToken: cts.Token); }); } -} \ No newline at end of file + + + [Fact] + public async Task TestMergeSameCellsWithTagAsync() + { + var path = PathHelper.GetFile("xlsx/TestMergeWithTag.xlsx"); + using var mergedFilePath = AutoDeletingPath.Create(); + + await _excelTemplater.MergeSameCellsAsync(mergedFilePath.ToString(), path); + var mergedCells = SheetHelper.GetFirstSheetMergedCells(mergedFilePath.ToString()); + + Assert.Equal("A2:A4", mergedCells[0]); + Assert.Equal("C3:C4", mergedCells[1]); + Assert.Equal("A7:A8", mergedCells[2]); + } + + [Fact] + public async Task TestMergeSameCellsWithLimitTagAsync() + { + var path = PathHelper.GetFile("xlsx/TestMergeWithLimitTag.xlsx"); + using var mergedFilePath = AutoDeletingPath.Create(); + + await _excelTemplater.MergeSameCellsAsync(mergedFilePath.ToString(), path); + var mergedCells = SheetHelper.GetFirstSheetMergedCells(mergedFilePath.ToString()); + + Assert.Equal("A3:A4", mergedCells[0]); + Assert.Equal("C3:C6", mergedCells[1]); + Assert.Equal("A5:A6", mergedCells[2]); + } + + + [Fact] + public async Task TestIEnumerableWithFormulas() + { + var templatePath = PathHelper.GetFile("xlsx/TestTemplateBasicIEnumerableFillWithFormulas.xlsx"); + using var path = AutoDeletingPath.Create(); + + var value = new + { + employees = new[] + { + new { name = "Jack", department = "HR", salary = 90000 }, + new { name = "Lisa", department = "HR", salary = 150000 }, + new { name = "John", department = "HR", salary = 64000 }, + new { name = "Mike", department = "IT", salary = 87000 }, + new { name = "Neo", department = "IT", salary = 98000 }, + new { name = "Joan", department = "IT", salary = 120000 } + } + }; + await _excelTemplater.FillTemplateAsync(path.FilePath, templatePath, value, true); + + var dimension = SheetHelper.GetFirstSheetDimensionRefValue(path.FilePath); + Assert.Equal("A1:C13", dimension); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs index 26add7d0..7f96a86d 100644 --- a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs @@ -938,7 +938,7 @@ public void TemplateTest() } [Fact] - public void MergeSameCellsWithTagTest() + public void TestMergeSameCellsWithTag() { var path = PathHelper.GetFile("xlsx/TestMergeWithTag.xlsx"); using var mergedFilePath = AutoDeletingPath.Create(); @@ -952,7 +952,7 @@ public void MergeSameCellsWithTagTest() } [Fact] - public void MergeSameCellsWithLimitTagTest() + public void TestMergeSameCellsWithLimitTag() { var path = PathHelper.GetFile("xlsx/TestMergeWithLimitTag.xlsx"); using var mergedFilePath = AutoDeletingPath.Create();