From 7194225eeb5f2201dcd3c0ab6a679db02bfcebb1 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Fri, 3 Apr 2026 01:03:18 +0200 Subject: [PATCH 01/11] Updating schemas --- src/MiniExcel.OpenXml/Constants/Schemas.cs | 17 +++++++---- src/MiniExcel.OpenXml/OpenXmlReader.cs | 8 ++--- src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs | 2 +- .../Templates/OpenXmlTemplate.Impl.cs | 30 +++++++++---------- .../Templates/OpenXmlTemplate.cs | 2 +- .../Utils/XmlReaderHelper.cs | 2 +- 6 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/MiniExcel.OpenXml/Constants/Schemas.cs b/src/MiniExcel.OpenXml/Constants/Schemas.cs index 72c2e08f..35f3f079 100644 --- a/src/MiniExcel.OpenXml/Constants/Schemas.cs +++ b/src/MiniExcel.OpenXml/Constants/Schemas.cs @@ -2,9 +2,16 @@ internal static class Schemas { - public const string SpreadsheetmlXmlns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; - public const string SpreadsheetmlXmlStrictns = "http://purl.oclc.org/ooxml/spreadsheetml/main"; - public const string SpreadsheetmlXmlRelationshipns = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; - public const string SpreadsheetmlXmlStrictRelationshipns = "http://purl.oclc.org/ooxml/officeDocument/relationships"; + public const string SpreadsheetmlXmlNs = "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"; + public const string SpreadsheetmlXmlRelationships = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + public const string SpreadsheetmlXmlStrictRelationships = "http://purl.oclc.org/ooxml/officeDocument/relationships"; + public const string SpreadsheetmlXmlComments = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; + public const string SpreadsheetmlXmlThreadedComment = "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment"; + public const string SpreadsheetmlXmlX14Ac = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac"; -} \ No newline at end of file + public const string SpreadsheetmlXmlX18Tc = "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"; + public const string SpreadsheetmlXmlX14R = "http://schemas.microsoft.com/office/spreadsheetml/2014/revision"; +} diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index d9c23796..3a0a1149 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -9,8 +9,8 @@ namespace MiniExcelLib.OpenXml; internal partial class OpenXmlReader : IMiniExcelReader { - private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlns, Schemas.SpreadsheetmlXmlStrictns]; - private static readonly string[] RelationshiopNs = [Schemas.SpreadsheetmlXmlRelationshipns, Schemas.SpreadsheetmlXmlStrictRelationshipns]; + private static readonly string[] Ns = [Schemas.SpreadsheetmlXmlNs, Schemas.SpreadsheetmlXmlStrictNs]; + private static readonly string[] RelationshiopNs = [Schemas.SpreadsheetmlXmlRelationships, Schemas.SpreadsheetmlXmlStrictRelationships]; private readonly OpenXmlConfiguration _config; private List? _sheetRecords; @@ -567,14 +567,14 @@ await reader.SkipAsync() #endif using var reader = XmlReader.Create(stream, xmlSettings); - if (!XmlReaderHelper.IsStartElement(reader, "Relationships", "http://schemas.openxmlformats.org/package/2006/relationships")) + if (!XmlReaderHelper.IsStartElement(reader, "Relationships", Schemas.OpenXmlPackageRelationships)) return null; if (!await XmlReaderHelper.ReadFirstContentAsync(reader, cancellationToken).ConfigureAwait(false)) return null; while (!reader.EOF) { - if (XmlReaderHelper.IsStartElement(reader, "Relationship", "http://schemas.openxmlformats.org/package/2006/relationships")) + if (XmlReaderHelper.IsStartElement(reader, "Relationship", Schemas.OpenXmlPackageRelationships)) { var rid = reader.GetAttribute("Id"); foreach (var sheet in sheetRecords.Where(sh => sh.Rid == rid)) diff --git a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs index 637a20b3..fa11822e 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.SpreadsheetmlXmlNs, 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 91b69bc0..2c77b1cc 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs @@ -967,7 +967,7 @@ private void ProcessFormulas(StringBuilder rowXml, int rowIndex) if (!v.InnerText.StartsWith("$=")) continue; - var fNode = c.OwnerDocument.CreateElement("f", Schemas.SpreadsheetmlXmlns); + var fNode = c.OwnerDocument.CreateElement("f", Schemas.SpreadsheetmlXmlNs); fNode.InnerText = v.InnerText[2..]; c.InsertBefore(fNode, v); c.RemoveChild(v); @@ -1022,11 +1022,11 @@ private static void ReplaceSharedStringsToStr(IDictionary sharedStr 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); + ? 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); + ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); tNode.InnerText = shared; isNode.AppendChild(tNode); c.AppendChild(isNode); @@ -1054,11 +1054,11 @@ private static void SetCellType(XmlElement c, string type) 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); + ? 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); + ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); tNode.InnerText = text; isNode.AppendChild(tNode); c.AppendChild(isNode); @@ -1067,11 +1067,11 @@ private static void SetCellType(XmlElement c, string type) { // Create empty if neither nor exists var isNode = string.IsNullOrEmpty(prefix) - ? c.OwnerDocument.CreateElement("is", Schemas.SpreadsheetmlXmlns) - : c.OwnerDocument.CreateElement(prefix, "is", Schemas.SpreadsheetmlXmlns); + ? 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); + ? c.OwnerDocument.CreateElement("t", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "t", Schemas.SpreadsheetmlXmlNs); isNode.AppendChild(tNode); c.AppendChild(isNode); } @@ -1093,8 +1093,8 @@ private static void SetCellType(XmlElement c, string type) 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); + ? c.OwnerDocument.CreateElement("v", Schemas.SpreadsheetmlXmlNs) + : c.OwnerDocument.CreateElement(prefix, "v", Schemas.SpreadsheetmlXmlNs); v.InnerText = text; c.AppendChild(v); } diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs index b264e58b..2edceeae 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -22,7 +22,7 @@ internal partial class OpenXmlTemplate : IMiniExcelTemplate static OpenXmlTemplate() { Ns = new XmlNamespaceManager(new NameTable()); - Ns.AddNamespace("x", Schemas.SpreadsheetmlXmlns); + Ns.AddNamespace("x", Schemas.SpreadsheetmlXmlNs); Ns.AddNamespace("x14ac", Schemas.SpreadsheetmlXmlX14Ac); } diff --git a/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs b/src/MiniExcel.OpenXml/Utils/XmlReaderHelper.cs index da4a4e00..c0d9c826 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.SpreadsheetmlXmlNs, Schemas.SpreadsheetmlXmlStrictNs]; /// /// Pass <?xml> and <worksheet> From 446f0d2546abf8956175e0296a26747f2100abcf Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Fri, 3 Apr 2026 01:07:49 +0200 Subject: [PATCH 02/11] Added missing ConfiguredAsyncDisposable calls to various streams --- src/MiniExcel.OpenXml/OpenXmlReader.cs | 74 ++++++++++++++++++++------ 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index 3a0a1149..d43e0d82 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -90,9 +90,7 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC { var query = QueryRangeAsync(false, sheetName, startCell, endCell, cancellationToken); if (!CellReferenceConverter.TryParseCellReference(startCell, out _, out var rowOffset)) - { throw new InvalidDataException($"Value {startCell} is not a valid cell reference."); - } return MiniExcelMapper.MapQueryAsync(query, rowOffset, treatHeaderAsData, _config.TrimColumnNames, _config, XmlHelper.DecodeString, cancellationToken); } @@ -178,7 +176,11 @@ internal static async Task CreateAsync(Stream stream, IMiniExcelC } #if NET10_0_OR_GREATER - using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheetEntry.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheetEntry.Open(); #endif @@ -425,7 +427,11 @@ private async Task SetSharedStringsAsync(CancellationToken cancellationToken = d var idx = 0; #if NET10_0_OR_GREATER - using var stream = await sharedStringsEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var stream = await sharedStringsEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var stream = sharedStringsEntry.Open(); + await using var disposableStream = stream.ConfigureAwait(false); #else using var stream = sharedStringsEntry.Open(); #endif @@ -453,7 +459,7 @@ private void SetWorkbookRels(ReadOnlyCollection entries) } [CreateSyncVersion] - internal static async IAsyncEnumerable ReadWorkbookAsync(ReadOnlyCollection entries, [EnumeratorCancellation] CancellationToken cancellationToken = default) + private static async IAsyncEnumerable ReadWorkbookAsync(ReadOnlyCollection entries, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var xmlSettings = XmlReaderHelper.GetXmlReaderSettings( #if SYNC_ONLY @@ -465,7 +471,11 @@ internal static async IAsyncEnumerable ReadWorkbookAsync(ReadOnlyCo var entry = entries.Single(w => w.FullName == "xl/workbook.xml"); #if NET10_0_OR_GREATER - using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var stream = entry.Open(); + await using var disposableStream = stream.ConfigureAwait(false); #else using var stream = entry.Open(); #endif @@ -561,7 +571,11 @@ await reader.SkipAsync() var entry = entries.Single(w => w.FullName == "xl/_rels/workbook.xml.rels"); #if NET10_0_OR_GREATER - using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var stream = entry.Open(); + await using var disposableStream = stream.ConfigureAwait(false); #else using var stream = entry.Open(); #endif @@ -766,11 +780,15 @@ internal async Task> GetDimensionsAsync(CancellationToken canc var withoutCr = false; #if NET10_0_OR_GREATER - using (var sheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false)) + var crSheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableCrSheetStream = crSheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var crSheetStream = sheet.Open(); + await using var disposableCrSheetStream = crSheetStream.ConfigureAwait(false); #else - using (var sheetStream = sheet.Open()) + using var crSheetStream = sheet.Open(); #endif - using (var reader = XmlReader.Create(sheetStream, xmlSettings)) + using (var reader = XmlReader.Create(crSheetStream, xmlSettings)) { while (await reader.ReadAsync().ConfigureAwait(false)) { @@ -818,7 +836,11 @@ internal async Task> GetDimensionsAsync(CancellationToken canc if (withoutCr) { #if NET10_0_OR_GREATER - using var sheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheet.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheet.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheet.Open(); #endif @@ -915,12 +937,17 @@ internal static async Task TryGetMaxRowColumnIndexAs bool withoutCr = false; int maxRowIndex = -1; int maxColumnIndex = -1; + #if NET10_0_OR_GREATER - using (var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false)) + var crSheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableCrSheetStream = crSheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var crSheetStream = sheetEntry.Open(); + await using var disposableCrSheetStream = crSheetStream.ConfigureAwait(false); #else - using (var sheetStream = sheetEntry.Open()) + using var crSheetStream = sheetEntry.Open(); #endif - using (var reader = XmlReader.Create(sheetStream, xmlSettings)) + using (var reader = XmlReader.Create(crSheetStream, xmlSettings)) { while (await reader.ReadAsync() #if NET6_0_OR_GREATER @@ -970,7 +997,11 @@ internal static async Task TryGetMaxRowColumnIndexAs if (withoutCr) { #if NET10_0_OR_GREATER - using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheetEntry.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheetEntry.Open(); #endif @@ -978,6 +1009,7 @@ internal static async Task TryGetMaxRowColumnIndexAs if (!XmlReaderHelper.IsStartElement(reader, "worksheet", Ns)) return new GetMaxRowColumnIndexResult(false); + if (!await XmlReaderHelper.ReadFirstContentAsync(reader, cancellationToken).ConfigureAwait(false)) return new GetMaxRowColumnIndexResult(false); @@ -1048,7 +1080,11 @@ internal static async Task TryGetMergeCellsAsync(ZipArchiveEntry sheetEntr var mergeCells = new MergeCells(); #if NET10_0_OR_GREATER - using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheetEntry.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheetEntry.Open(); #endif @@ -1140,7 +1176,11 @@ internal async IAsyncEnumerable QueryMappedAsync( }; #if NET10_0_OR_GREATER - using var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var sheetStream = sheetEntry.Open(); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); #else using var sheetStream = sheetEntry.Open(); #endif From db149e6e0d1030f4a3b36695cb9b48cc4662b46d Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Fri, 3 Apr 2026 12:21:48 +0200 Subject: [PATCH 03/11] Added implementation for extracting comments --- src/MiniExcel.OpenXml/Models/Comments.cs | 44 ++++++ src/MiniExcel.OpenXml/OpenXmlReader.cs | 187 ++++++++++++++++++++++- 2 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 src/MiniExcel.OpenXml/Models/Comments.cs diff --git a/src/MiniExcel.OpenXml/Models/Comments.cs b/src/MiniExcel.OpenXml/Models/Comments.cs new file mode 100644 index 00000000..c480cdba --- /dev/null +++ b/src/MiniExcel.OpenXml/Models/Comments.cs @@ -0,0 +1,44 @@ +namespace MiniExcelLib.OpenXml.Models; + +public class CommentResultSet +{ + public IReadOnlyList Comments { get; internal set; } = []; + public IReadOnlyList Notes { get; internal set; } = []; +} + +public class ThreadedComment +{ + public Guid Id { get; internal set; } + public string ReferenceCell { get; internal set; } = null!; + public Author? Author { get; internal set; } + public bool Active { get; internal set; } + public string? FirstMessage { get; internal set; } + public DateTime CreatedAt { get; internal set; } + + internal List ThreadedComments = []; + public IReadOnlyList Replies => ThreadedComments; +} + +public class ThreadedCommentReply +{ + public Guid Id { get; internal set; } + public Guid? ParentId { get; internal set; } + public Author? Author { get; internal set; } + public DateTime ReplyTime { get; internal set; } + public string? Text { get; internal set; } +} + +public class NoteComment +{ + public Guid Id { get; internal set; } + public string? ReferenceCell { get; internal set; } + public string? Author { get; internal set; } + public string? Text { get; internal set; } +} + +public class Author +{ + public Guid Id { get; internal set; } + public string DisplayName { get; internal set; } = ""; + public string? ProviderId { get; internal set; } +} diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index d43e0d82..0fdfacfe 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Xml.Linq; using MiniExcelLib.Core; using MiniExcelLib.OpenXml.Constants; using MiniExcelLib.OpenXml.Styles; @@ -1140,9 +1141,186 @@ internal static async Task TryGetMergeCellsAsync(ZipArchiveEntry sheetEntr return true; } - ~OpenXmlReader() + [CreateSyncVersion] + internal async Task ReadCommentsAsync(string sheetName, CancellationToken cancellationToken = default) { - Dispose(false); + if (string.IsNullOrEmpty(sheetName)) + throw new ArgumentException("sheetName cannot be null or empty", nameof(sheetName)); + + XNamespace nsRel = Schemas.OpenXmlPackageRelationships; + XNamespace ns18Tc = Schemas.SpreadsheetmlXmlX18Tc; + XNamespace nsMain = Schemas.SpreadsheetmlXmlNs; + XNamespace ns14R = Schemas.SpreadsheetmlXmlX14R; + + SetWorkbookRels(Archive.EntryCollection); + var sheetRecord = _sheetRecords?.SingleOrDefault(s => s.Name.Equals(sheetName, StringComparison.CurrentCultureIgnoreCase)); + if (sheetRecord?.Path.Split('/')[^1] is not { } sheetFile) + throw new InvalidDataException($"There is no sheet named {sheetName}"); + + List people = []; + if (Archive.GetEntry("xl/persons/person.xml") is { } persons) + { +#if NET10_0_OR_GREATER + var personStream = await persons.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposablePersonStream = personStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var personStream = persons.Open(); + await using var disposablePersonStream = personStream.ConfigureAwait(false); +#else + using var personStream = persons.Open(); +#endif + +#if NETSTANDARD2_0 + var personDoc = XDocument.Load(personStream, LoadOptions.None); +#else + var personDoc = await XDocument.LoadAsync(personStream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#endif + var personElements = personDoc.Root?.Elements(ns18Tc + "person"); + people = personElements + ?.Select(p => new Author + { + Id = Guid.Parse(p.Attribute("id")!.Value), + DisplayName = p.Attribute("displayName")?.Value is { } name and not "" ? name : "???", + ProviderId = p.Attribute("providerId")?.Value, + }) + .ToList() ?? []; + } + + var rel = Archive.EntryCollection.Single(x => x.FullName == $"xl/worksheets/_rels/{sheetFile}.rels"); +#if NET10_0_OR_GREATER + var stream = await rel.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var stream = rel.Open(); + await using var disposableStream = stream.ConfigureAwait(false); +#else + using var stream = rel.Open(); +#endif + +#if NETSTANDARD2_0 + var relDoc = XDocument.Load(stream, LoadOptions.None); +#else + var relDoc = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#endif + + var threadedCommentRels = relDoc.Root?.Elements(nsRel + "Relationship"); + var threadedCommentsPath = threadedCommentRels + ?.FirstOrDefault(x => x.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlThreadedComment) + ?.Attribute("Target")?.Value.TrimStart('.', '/'); + + var noteRels = relDoc.Root?.Elements(nsRel + "Relationship"); + var notesPath = noteRels + ?.FirstOrDefault(x => x.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlComments) + ?.Attribute("Target")?.Value.TrimStart('.', '/'); + + List commentThreads = []; + List notes = []; + string[] refCells = []; + if (Archive.GetEntry($"xl/{threadedCommentsPath}") is { } threadEntry) + { +#if NET10_0_OR_GREATER + var threadEntryStream = await threadEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableThreadEntryStream = threadEntryStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var threadEntryStream = threadEntry.Open(); + await using var disposableThreadEntryStream = threadEntryStream.ConfigureAwait(false); +#else + using var threadEntryStream = threadEntry.Open(); +#endif + +#if NETSTANDARD2_0 + var doc = XDocument.Load(threadEntryStream, LoadOptions.None); +#else + var doc = await XDocument.LoadAsync(threadEntryStream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#endif + + var commentThreadElements = doc.Root?.Elements(ns18Tc + "threadedComment"); + commentThreads = commentThreadElements + ?.Where(tc => tc.Attribute("parentId") is null) + .Select(tc => new ThreadedComment + { + Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), + Author = people.FirstOrDefault(p => p.Id == (Guid.TryParse(tc.Attribute("personId")?.Value, out var person) ? person : Guid.Empty)), + CreatedAt = DateTime.Parse(tc.Attribute("dT")!.Value), + ReferenceCell = tc.Attribute("ref")?.Value!, + FirstMessage = tc.Value, + Active = tc.Attribute("done")?.Value is not (null or "0") + }) + .ToList() ?? []; + + var replyElements = doc.Root?.Elements(ns18Tc + "threadedComment"); + var replies = replyElements + ?.Where(tc => tc.Attribute("parentId") is not null) + .Select(tc => new ThreadedCommentReply + { + Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), + ParentId = Guid.Parse(tc.Attribute("parentId")!.Value), + Author = people.FirstOrDefault(p => p.Id == Guid.Parse(tc.Attribute("personId")!.Value)), + ReplyTime = DateTime.Parse(tc.Attribute("dT")!.Value), + Text = tc.Value + }) + .ToLookup(x => x.ParentId); + + if (replies is not null) + { + foreach (var thread in commentThreads) + { + thread.ThreadedComments = replies[thread.Id].ToList(); + } + } + + refCells = [..commentThreads.Select(x => x.ReferenceCell).Distinct()]; + } + + if (Archive.GetEntry($"xl/{notesPath}") is { } noteEntry) + { +#if NET10_0_OR_GREATER + var noteEntryStream = await noteEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableNoteEntryStream = noteEntryStream.ConfigureAwait(false); +#elif !NETSTANDARD2_0 + var noteEntryStream = noteEntry.Open(); + await using var disposableNoteEntryStream = noteEntryStream.ConfigureAwait(false); +#else + using var noteEntryStream = noteEntry.Open(); +#endif + +#if NETSTANDARD2_0 + var doc = XDocument.Load(noteEntryStream, LoadOptions.None); +#else + var doc = await XDocument.LoadAsync(noteEntryStream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#endif + + var authorElements = doc.Root?.Element(nsMain + "authors")?.Elements(nsMain + "author"); + var authors = authorElements?.Select(a => a.Value).ToArray(); + + var commentElements = doc.Root + ?.Element(nsMain + "commentList") + ?.Elements(nsMain + "comment"); + + notes = commentElements + ?.Where(c => !refCells.Contains(c.Attribute("ref")?.Value)) + .Select(c => new NoteComment + { + Id = Guid.Parse(c.Attribute(ns14R + "uid")!.Value.Trim('{', '}')), + Author = authors?.ElementAtOrDefault(int.Parse(c.Attribute("authorId")!.Value)), + ReferenceCell = c.Attribute("ref")?.Value, + Text = string.Join("", GetTextFromComment(c)) + }) + .ToList() ?? []; + } + + return new CommentResultSet + { + Comments = commentThreads, + Notes = notes + }; + + IEnumerable GetTextFromComment(XElement? comment) + { + return comment?.Element(nsMain + "text") is { } textElement + ? textElement.Elements(nsMain + "r").Select(r => r.Element(nsMain + "t")?.Value) + : []; + } } /// @@ -1311,4 +1489,9 @@ protected void Dispose(bool disposing) _disposed = true; } } + + ~OpenXmlReader() + { + Dispose(false); + } } From fbedabdd03d07da2f3214092c2820db3cdd75a5c Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Fri, 3 Apr 2026 18:40:55 +0200 Subject: [PATCH 04/11] Added ConfiguredAsyncDisposable calls to OpenXmlImporter api methods --- .../WriteAdapters/DataReaderWriteAdapter.cs | 2 +- src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs | 50 +++++++++++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/MiniExcel.Core/WriteAdapters/DataReaderWriteAdapter.cs b/src/MiniExcel.Core/WriteAdapters/DataReaderWriteAdapter.cs index 952e246c..cc100b7c 100644 --- a/src/MiniExcel.Core/WriteAdapters/DataReaderWriteAdapter.cs +++ b/src/MiniExcel.Core/WriteAdapters/DataReaderWriteAdapter.cs @@ -18,7 +18,7 @@ public List GetColumns() { var columnName = _reader.GetName(i); if (!_configuration.DynamicColumnFirst || - _configuration.DynamicColumns.Any(d => string.Equals(d.Key, columnName, StringComparison.OrdinalIgnoreCase))) + _configuration.DynamicColumns?.Any(d => string.Equals(d.Key, columnName, StringComparison.OrdinalIgnoreCase)) is true) { var map = ColumnMappingsProvider.GetColumnMappingFromDynamicConfiguration(columnName, _configuration); mappings.Add(map); diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs index 17c6ee16..c6c4cce2 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs @@ -1,4 +1,3 @@ -using System.Dynamic; using MiniExcelLib.OpenXml; using OpenXmlReader = MiniExcelLib.OpenXml.OpenXmlReader; @@ -16,11 +15,14 @@ public async IAsyncEnumerable QueryAsync(string path, string? sheetName = string startCell = "A1", bool treatHeaderAsData = false, OpenXmlConfiguration? configuration = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) where T : class, new() { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); - +#endif var query = QueryAsync(stream, sheetName, startCell, treatHeaderAsData, configuration, cancellationToken); - //Foreach yield return twice reason : https://stackoverflow.com/questions/66791982/ienumerable-extract-code-lazy-loading-show-stream-was-not-readable await foreach (var item in query.ConfigureAwait(false)) yield return item; } @@ -40,7 +42,13 @@ public async IAsyncEnumerable QueryAsync(string path, bool useHeaderRow string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif + await foreach (var item in QueryAsync(stream, useHeaderRow, sheetName, startCell, configuration, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -72,7 +80,12 @@ public async IAsyncEnumerable QueryRangeAsync(string path, bool useHead string? sheetName = null, string startCell = "A1", string endCell = "", OpenXmlConfiguration? configuration = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif await foreach (var item in QueryRangeAsync(stream, useHeaderRow, sheetName, startCell, endCell, configuration, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -93,7 +106,12 @@ public async IAsyncEnumerable QueryRangeAsync(string path, bool useHead int? endColumnIndex = null, OpenXmlConfiguration? configuration = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif await foreach (var item in QueryRangeAsync(stream, useHeaderRow, sheetName, startRowIndex, startColumnIndex, endRowIndex, endColumnIndex, configuration, cancellationToken).ConfigureAwait(false)) yield return item; } @@ -121,7 +139,12 @@ public async Task QueryAsDataTableAsync(string path, bool useHeaderRo string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await QueryAsDataTableAsync(stream, useHeaderRow, sheetName, startCell, configuration, cancellationToken).ConfigureAwait(false); } @@ -133,7 +156,6 @@ public async Task QueryAsDataTableAsync(Stream stream, bool useHeader string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { - /*Issue #279*/ sheetName ??= (await GetSheetNamesAsync(stream, configuration, cancellationToken).ConfigureAwait(false)).First(); var dt = new DataTable(sheetName); @@ -187,7 +209,12 @@ public async Task QueryAsDataTableAsync(Stream stream, bool useHeader [CreateSyncVersion] public async Task> GetSheetNamesAsync(string path, OpenXmlConfiguration? config = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await GetSheetNamesAsync(stream, config, cancellationToken).ConfigureAwait(false); } @@ -207,7 +234,12 @@ public async Task> GetSheetNamesAsync(Stream stream, OpenXmlConfigu [CreateSyncVersion] public async Task> GetSheetInformationsAsync(string path, OpenXmlConfiguration? config = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await GetSheetInformationsAsync(stream, config, cancellationToken).ConfigureAwait(false); } @@ -226,7 +258,12 @@ public async Task> GetSheetInformationsAsync(Stream stream, Open [CreateSyncVersion] public async Task> GetSheetDimensionsAsync(string path, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await GetSheetDimensionsAsync(stream, cancellationToken).ConfigureAwait(false); } @@ -242,7 +279,12 @@ public async Task> GetColumnNamesAsync(string path, bool use string? sheetName = null, string startCell = "A1", OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = FileHelper.OpenSharedRead(path); +#endif return await GetColumnNamesAsync(stream, useHeaderRow, sheetName, startCell, configuration, cancellationToken).ConfigureAwait(false); } From 923a334956bf4c2c9b5fdcc00ad4b0f5c2db06ff Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Fri, 3 Apr 2026 19:02:51 +0200 Subject: [PATCH 05/11] Added public api to OpenXmlImporter for retrieving comments --- src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs | 19 +++++++++++++++++++ src/MiniExcel.OpenXml/OpenXmlReader.cs | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs index c6c4cce2..535602a4 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs @@ -302,6 +302,25 @@ public async Task> GetColumnNamesAsync(Stream stream, bool u return []; } + [CreateSyncVersion] + public async Task RetrieveCommentsAsync(string path, string? sheetName, CancellationToken cancellationToken = default) + { +#if NET8_0_OR_GREATER + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); +#else + using var stream = FileHelper.OpenSharedRead(path); +#endif + return await RetrieveCommentsAsync(stream, sheetName, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + public async Task RetrieveCommentsAsync(Stream stream, string? sheetName, CancellationToken cancellationToken = default) + { + using var reader = await OpenXmlReader.CreateAsync(stream, null, cancellationToken).ConfigureAwait(false); + return await reader.ReadCommentsAsync(sheetName, cancellationToken).ConfigureAwait(false); + } + #endregion #region DataReader diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index 0fdfacfe..cdd45977 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -1142,7 +1142,7 @@ internal static async Task TryGetMergeCellsAsync(ZipArchiveEntry sheetEntr } [CreateSyncVersion] - internal async Task ReadCommentsAsync(string sheetName, CancellationToken cancellationToken = default) + internal async Task ReadCommentsAsync(string? sheetName, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(sheetName)) throw new ArgumentException("sheetName cannot be null or empty", nameof(sheetName)); From e53556e8f64ff6506ec818753c1fb39d0ca5b2fb Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Apr 2026 01:23:21 +0200 Subject: [PATCH 06/11] Fixed some minor details in the implementation for retrieving comments --- src/Directory.Packages.props | 2 +- src/MiniExcel.OpenXml/Models/Comments.cs | 18 +++++++++---- src/MiniExcel.OpenXml/OpenXmlReader.cs | 32 +++++++++++------------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8c3973a8..b152565c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,6 +1,6 @@ - + diff --git a/src/MiniExcel.OpenXml/Models/Comments.cs b/src/MiniExcel.OpenXml/Models/Comments.cs index c480cdba..f4d8ad53 100644 --- a/src/MiniExcel.OpenXml/Models/Comments.cs +++ b/src/MiniExcel.OpenXml/Models/Comments.cs @@ -2,8 +2,16 @@ namespace MiniExcelLib.OpenXml.Models; public class CommentResultSet { - public IReadOnlyList Comments { get; internal set; } = []; - public IReadOnlyList Notes { get; internal set; } = []; + internal CommentResultSet(string sheetName, List comments, List notes) + { + SheetName = sheetName; + Comments = comments; + Notes = notes; + } + + public string SheetName { get; } + public IReadOnlyList Comments { get; } + public IReadOnlyList Notes { get; } } public class ThreadedComment @@ -11,9 +19,9 @@ public class ThreadedComment public Guid Id { get; internal set; } public string ReferenceCell { get; internal set; } = null!; public Author? Author { get; internal set; } - public bool Active { get; internal set; } + public bool Resolved { get; internal set; } public string? FirstMessage { get; internal set; } - public DateTime CreatedAt { get; internal set; } + public DateTime CreationTime { get; internal set; } internal List ThreadedComments = []; public IReadOnlyList Replies => ThreadedComments; @@ -25,7 +33,7 @@ public class ThreadedCommentReply public Guid? ParentId { get; internal set; } public Author? Author { get; internal set; } public DateTime ReplyTime { get; internal set; } - public string? Text { get; internal set; } + public string? ReplyText { get; internal set; } } public class NoteComment diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index cdd45977..0a261ef7 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -1146,7 +1146,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance { if (string.IsNullOrEmpty(sheetName)) throw new ArgumentException("sheetName cannot be null or empty", nameof(sheetName)); - + XNamespace nsRel = Schemas.OpenXmlPackageRelationships; XNamespace ns18Tc = Schemas.SpreadsheetmlXmlX18Tc; XNamespace nsMain = Schemas.SpreadsheetmlXmlNs; @@ -1185,8 +1185,10 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance }) .ToList() ?? []; } - - var rel = Archive.EntryCollection.Single(x => x.FullName == $"xl/worksheets/_rels/{sheetFile}.rels"); + + if (Archive.EntryCollection.SingleOrDefault(x => x.FullName == $"xl/worksheets/_rels/{sheetFile}.rels") is not { } rel) + return new CommentResultSet(sheetName, [], []); + #if NET10_0_OR_GREATER var stream = await rel.OpenAsync(cancellationToken).ConfigureAwait(false); await using var disposableStream = stream.ConfigureAwait(false); @@ -1204,14 +1206,14 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance #endif var threadedCommentRels = relDoc.Root?.Elements(nsRel + "Relationship"); - var threadedCommentsPath = threadedCommentRels - ?.FirstOrDefault(x => x.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlThreadedComment) - ?.Attribute("Target")?.Value.TrimStart('.', '/'); + var threadedCommentsElement = threadedCommentRels?.FirstOrDefault(x => x.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlThreadedComment); + var threadedCommentsTarget = threadedCommentsElement?.Attribute("Target"); + var threadedCommentsPath = threadedCommentsTarget?.Value.TrimStart('.', '/'); var noteRels = relDoc.Root?.Elements(nsRel + "Relationship"); - var notesPath = noteRels - ?.FirstOrDefault(x => x.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlComments) - ?.Attribute("Target")?.Value.TrimStart('.', '/'); + var notesElement = noteRels?.FirstOrDefault(x => x.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlComments); + var notesTarget = notesElement?.Attribute("Target"); + var notesPath = notesTarget?.Value.TrimStart('.', '/'); List commentThreads = []; List notes = []; @@ -1241,10 +1243,10 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance { Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), Author = people.FirstOrDefault(p => p.Id == (Guid.TryParse(tc.Attribute("personId")?.Value, out var person) ? person : Guid.Empty)), - CreatedAt = DateTime.Parse(tc.Attribute("dT")!.Value), + CreationTime = DateTime.Parse(tc.Attribute("dT")!.Value), ReferenceCell = tc.Attribute("ref")?.Value!, FirstMessage = tc.Value, - Active = tc.Attribute("done")?.Value is not (null or "0") + Resolved = tc.Attribute("done")?.Value is not (null or "0") }) .ToList() ?? []; @@ -1257,7 +1259,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance ParentId = Guid.Parse(tc.Attribute("parentId")!.Value), Author = people.FirstOrDefault(p => p.Id == Guid.Parse(tc.Attribute("personId")!.Value)), ReplyTime = DateTime.Parse(tc.Attribute("dT")!.Value), - Text = tc.Value + ReplyText = tc.Value }) .ToLookup(x => x.ParentId); @@ -1309,11 +1311,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance .ToList() ?? []; } - return new CommentResultSet - { - Comments = commentThreads, - Notes = notes - }; + return new CommentResultSet(sheetName, commentThreads, notes); IEnumerable GetTextFromComment(XElement? comment) { From 3a9490b3f6c18f44f1790fe227d0e57fbca7f814 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Apr 2026 01:23:27 +0200 Subject: [PATCH 07/11] Added an Excel sample and tests for the comment retrieval functionality --- .../Comments/CommentsRetrievalAsyncTests.cs | 126 ++++++++++++++++++ .../Comments/CommentsRetrievalTests.cs | 125 +++++++++++++++++ tests/data/xlsx/TestCommentsAndNotes.xlsx | Bin 0 -> 19436 bytes 3 files changed, 251 insertions(+) create mode 100644 tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs create mode 100644 tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs create mode 100644 tests/data/xlsx/TestCommentsAndNotes.xlsx diff --git a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs new file mode 100644 index 00000000..481cf9eb --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs @@ -0,0 +1,126 @@ +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests.Comments; + +public class CommentsRetrievalAsyncTests +{ + private readonly OpenXmlImporter _excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); + + [Fact] + public async Task SheetWithCommentsAndNotesTestAsync() + { + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet1"); + var (firstComment, secondComment) = (commentSet.Comments[0], commentSet.Comments[1]); + + Assert.Equal("sheet1", commentSet.SheetName, ignoreCase: true); + Assert.Equal(2, commentSet.Comments.Count); + + Assert.Equal("B3", firstComment.ReferenceCell); + Assert.Equal(new DateTime(2026, 3, 21, 12, 7, 24), firstComment.CreationTime); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Id); + Assert.Equal("this is a comment", firstComment.FirstMessage); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), firstComment.Author?.Id); + Assert.Equal("John Doe", firstComment.Author?.DisplayName); + Assert.Equal("google-sheets", firstComment.Author?.ProviderId); + Assert.Equal(2, firstComment.Replies.Count); + Assert.False(firstComment.Resolved); + + Assert.Equal(new Guid("dfb1d4cd-7f1f-42ae-9f61-330f03f0b9ad"), firstComment.Replies[0].Id); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[0].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 17, 45), firstComment.Replies[0].ReplyTime); + Assert.Equal("Mary Sue", firstComment.Replies[0].Author?.DisplayName); + Assert.Equal("this is a reply", firstComment.Replies[0].ReplyText); + + Assert.Equal(new Guid("d99bde2c-3df5-4300-a12e-2cc3b831c5dd"), firstComment.Replies[1].Id); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[1].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 3), firstComment.Replies[1].ReplyTime); + Assert.Equal("John Doe", firstComment.Replies[1].Author?.DisplayName); + Assert.Equal("this is another reply", firstComment.Replies[1].ReplyText); + + Assert.Empty(secondComment.Replies); + Assert.Equal("E2", secondComment.ReferenceCell); + Assert.Equal(new Guid("0fdf4b1e-0d47-4717-9dd5-c9fc731b0ad6"), secondComment.Id); + Assert.Equal(new DateTime(2026, 3, 21, 21, 35, 17), secondComment.CreationTime); + Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), secondComment.Author?.Id); + Assert.Equal("Mary Sue", secondComment.Author?.DisplayName); + Assert.Equal("AD", secondComment.Author?.ProviderId); + Assert.False(secondComment.Resolved); + Assert.Equal("this is a separate comment", secondComment.FirstMessage); + + Assert.Equal(2, commentSet.Notes.Count); + var (firstNote, secondNote) = (commentSet.Notes[0], commentSet.Notes[1]); + + Assert.Equal(new Guid("00000000-0006-0000-0000-000001000000"), firstNote.Id); + Assert.Equal("D6", firstNote.ReferenceCell); + Assert.Empty(firstNote.Author ?? ""); + Assert.Equal("this is a simple note", firstNote.Text); + + Assert.Equal(new Guid("4e01653b-66e0-48be-9390-2bddb28a7255"), secondNote.Id); + Assert.Equal("G10", secondNote.ReferenceCell); + Assert.Equal("local user", secondNote.Author); + Assert.Equal("local user:\nthis is a note from someone else", secondNote.Text); + } + + [Fact] + public async Task SheetWithNotesAndCommentsWithoutRepliesTestAsync() + { + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet2"); + var comment = commentSet.Comments[0]; + + Assert.Equal("sheet2", commentSet.SheetName, ignoreCase: true); + Assert.Single(commentSet.Comments); + Assert.Empty(comment.Replies); + Assert.Equal("A3", comment.ReferenceCell); + Assert.Equal(new Guid("597d85de-079d-4129-8ebb-e6a9666c1c31"), comment.Id); + Assert.Equal(new DateTime(2026, 3, 21, 12, 8, 22), comment.CreationTime); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); + Assert.Equal("John Doe", comment.Author?.DisplayName); + Assert.Equal("google-sheets", comment.Author?.ProviderId); + Assert.False(comment.Resolved); + Assert.Equal("this is a comment on another sheet", comment.FirstMessage); + + Assert.Single(commentSet.Notes); + var note = commentSet.Notes[0]; + + Assert.Equal(new Guid("00000000-0006-0000-0100-000001000000"), note.Id); + Assert.Equal("B11", note.ReferenceCell); + Assert.Empty(commentSet.Notes[0].Author ?? ""); + Assert.Equal("this is a note on another sheet", note.Text); + } + + [Fact] + public async Task SheetWithoutNotesNorCommentsTestAsync() + { + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet3"); + Assert.Equal("sheet3", commentSet.SheetName, ignoreCase: true); + Assert.Empty(commentSet.Comments); + Assert.Empty(commentSet.Notes); + } + + [Fact] + public async Task SheetWithResolvedThreadedCommentsTestAsync() + { + var commentSet = await _excelImporter.RetrieveCommentsAsync(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet4"); + var comment = commentSet.Comments[0]; + + Assert.Single(commentSet.Comments); + Assert.Equal("sheet4", commentSet.SheetName, ignoreCase: true); + Assert.Equal("D2", comment.ReferenceCell); + Assert.Equal(new DateTime(2026, 3, 21, 12, 34, 24), comment.CreationTime); + Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Id); + Assert.Equal("this thread will be resolved", comment.FirstMessage); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); + Assert.Equal("John Doe", comment.Author?.DisplayName); + Assert.Equal("google-sheets", comment.Author?.ProviderId); + Assert.Single(comment.Replies); + Assert.True(comment.Resolved); + + Assert.Equal(new Guid("f4863ec3-3a84-453a-88a7-ed634a96dd18"), comment.Replies[0].Id); + Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Replies[0].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 55), comment.Replies[0].ReplyTime); + Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), comment.Replies[0].Author?.Id); + Assert.Equal("Mary Sue", comment.Replies[0].Author?.DisplayName); + Assert.Equal("AD", comment.Replies[0].Author?.ProviderId); + Assert.Equal("ok", comment.Replies[0].ReplyText); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs new file mode 100644 index 00000000..b3a0e8ea --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs @@ -0,0 +1,125 @@ +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests.Comments; + +public class CommentsRetrievalTests +{ + private readonly OpenXmlImporter _excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); + + [Fact] + public void SheetWithNotesAndCommentsWithRepliesTest() + { + var commentSet = _excelImporter.RetrieveComments(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet1"); + var (firstComment, secondComment) = (commentSet.Comments[0], commentSet.Comments[1]); + + Assert.Equal("sheet1", commentSet.SheetName, ignoreCase: true); + Assert.Equal(2, commentSet.Comments.Count); + + Assert.Equal("B3", firstComment.ReferenceCell); + Assert.Equal(new DateTime(2026, 3, 21, 12, 7, 24), firstComment.CreationTime); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Id); + Assert.Equal("this is a comment", firstComment.FirstMessage); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), firstComment.Author?.Id); + Assert.Equal("John Doe", firstComment.Author?.DisplayName); + Assert.Equal("google-sheets", firstComment.Author?.ProviderId); + Assert.Equal(2, firstComment.Replies.Count); + Assert.False(firstComment.Resolved); + + Assert.Equal(new Guid("dfb1d4cd-7f1f-42ae-9f61-330f03f0b9ad"), firstComment.Replies[0].Id); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[0].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 17, 45), firstComment.Replies[0].ReplyTime); + Assert.Equal("Mary Sue", firstComment.Replies[0].Author?.DisplayName); + Assert.Equal("this is a reply", firstComment.Replies[0].ReplyText); + + Assert.Equal(new Guid("d99bde2c-3df5-4300-a12e-2cc3b831c5dd"), firstComment.Replies[1].Id); + Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[1].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 3), firstComment.Replies[1].ReplyTime); + Assert.Equal("John Doe", firstComment.Replies[1].Author?.DisplayName); + Assert.Equal("this is another reply", firstComment.Replies[1].ReplyText); + + Assert.Empty(secondComment.Replies); + Assert.Equal("E2", secondComment.ReferenceCell); + Assert.Equal(new Guid("0fdf4b1e-0d47-4717-9dd5-c9fc731b0ad6"), secondComment.Id); + Assert.Equal(new DateTime(2026, 3, 21, 21, 35, 17), secondComment.CreationTime); + Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), secondComment.Author?.Id); + Assert.Equal("Mary Sue", secondComment.Author?.DisplayName); + Assert.Equal("AD", secondComment.Author?.ProviderId); + Assert.False(secondComment.Resolved); + Assert.Equal("this is a separate comment", secondComment.FirstMessage); + + Assert.Equal(2, commentSet.Notes.Count); + + Assert.Equal(new Guid("00000000-0006-0000-0000-000001000000"), commentSet.Notes[0].Id); + Assert.Equal("D6", commentSet.Notes[0].ReferenceCell); + Assert.Empty(commentSet.Notes[0].Author ?? ""); + Assert.Equal("this is a simple note", commentSet.Notes[0].Text); + + Assert.Equal(new Guid("4e01653b-66e0-48be-9390-2bddb28a7255"), commentSet.Notes[1].Id); + Assert.Equal("G10", commentSet.Notes[1].ReferenceCell); + Assert.Equal("local user", commentSet.Notes[1].Author); + Assert.Equal("local user:\nthis is a note from someone else", commentSet.Notes[1].Text); + } + + [Fact] + public void SheetWithNotesAndCommentsWithoutRepliesTest() + { + var commentSet = _excelImporter.RetrieveComments(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet2"); + var comment = commentSet.Comments[0]; + + Assert.Equal("sheet2", commentSet.SheetName, ignoreCase: true); + Assert.Single(commentSet.Comments); + Assert.Empty(comment.Replies); + Assert.Equal("A3", comment.ReferenceCell); + Assert.Equal(new Guid("597d85de-079d-4129-8ebb-e6a9666c1c31"), comment.Id); + Assert.Equal(new DateTime(2026, 3, 21, 12, 8, 22), comment.CreationTime); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); + Assert.Equal("John Doe", comment.Author?.DisplayName); + Assert.Equal("google-sheets", comment.Author?.ProviderId); + Assert.False(comment.Resolved); + Assert.Equal("this is a comment on another sheet", comment.FirstMessage); + + Assert.Single(commentSet.Notes); + var note = commentSet.Notes[0]; + + Assert.Equal(new Guid("00000000-0006-0000-0100-000001000000"), note.Id); + Assert.Equal("B11", note.ReferenceCell); + Assert.Empty(commentSet.Notes[0].Author ?? ""); + Assert.Equal("this is a note on another sheet", note.Text); + } + + [Fact] + public void SheetWithoutNotesNorCommentsTest() + { + var commentSet = _excelImporter.RetrieveComments(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet3"); + Assert.Equal("sheet3", commentSet.SheetName, ignoreCase: true); + Assert.Empty(commentSet.Comments); + Assert.Empty(commentSet.Notes); + } + + [Fact] + public void SheetWithResolvedThreadedCommentsTest() + { + var commentSet = _excelImporter.RetrieveComments(PathHelper.GetFile("xlsx/TestCommentsAndNotes.xlsx"), "sheet4"); + var comment = commentSet.Comments[0]; + + Assert.Single(commentSet.Comments); + Assert.Equal("sheet4", commentSet.SheetName, ignoreCase: true); + Assert.Equal("D2", comment.ReferenceCell); + Assert.Equal(new DateTime(2026, 3, 21, 12, 34, 24), comment.CreationTime); + Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Id); + Assert.Equal("this thread will be resolved", comment.FirstMessage); + Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); + Assert.Equal("John Doe", comment.Author?.DisplayName); + Assert.Equal("google-sheets", comment.Author?.ProviderId); + Assert.Single(comment.Replies); + Assert.True(comment.Resolved); + + Assert.Equal(new Guid("f4863ec3-3a84-453a-88a7-ed634a96dd18"), comment.Replies[0].Id); + Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Replies[0].ParentId); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 55), comment.Replies[0].ReplyTime); + Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), comment.Replies[0].Author?.Id); + Assert.Equal("Mary Sue", comment.Replies[0].Author?.DisplayName); + Assert.Equal("AD", comment.Replies[0].Author?.ProviderId); + Assert.Equal("ok", comment.Replies[0].ReplyText); + } +} diff --git a/tests/data/xlsx/TestCommentsAndNotes.xlsx b/tests/data/xlsx/TestCommentsAndNotes.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3846cad479f52cd51b8fd7ad241b400947409d7e GIT binary patch literal 19436 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 Pz`smLARsouAFuvD?u literal 0 HcmV?d00001 From 4b878a47ce6d2e1fab9cdce581fbad6d4217fa01 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Apr 2026 15:12:41 +0200 Subject: [PATCH 08/11] Minor adjustements --- src/MiniExcel.OpenXml/Models/SheetRecord.cs | 2 +- src/MiniExcel.OpenXml/OpenXmlReader.cs | 20 +++++++++---------- .../Picture/OpenXmlPictureImplement.cs | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/MiniExcel.OpenXml/Models/SheetRecord.cs b/src/MiniExcel.OpenXml/Models/SheetRecord.cs index 57747254..dded142a 100644 --- a/src/MiniExcel.OpenXml/Models/SheetRecord.cs +++ b/src/MiniExcel.OpenXml/Models/SheetRecord.cs @@ -6,7 +6,7 @@ internal sealed class SheetRecord(string name, string state, uint id, string rid public string State { get; set; } = state; public uint Id { get; } = id; public string Rid { get; set; } = rid; - public string Path { get; set; } + public string? Path { get; set; } public bool Active { get; } = active; public SheetInfo ToSheetInfo(uint index) diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index 0a261ef7..a32c8c4e 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -376,7 +376,7 @@ private ZipArchiveEntry GetSheetEntry(string? sheetName) var s = _sheetRecords[0]; sheetEntry = sheets.Single(w => w.FullName == $"xl/{s.Path}" || w.FullName == $"/xl/{s.Path}" || - w.FullName.TrimStart('/') == s.Path.TrimStart('/')); + w.FullName.TrimStart('/') == s.Path?.TrimStart('/')); } else { @@ -1154,7 +1154,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance SetWorkbookRels(Archive.EntryCollection); var sheetRecord = _sheetRecords?.SingleOrDefault(s => s.Name.Equals(sheetName, StringComparison.CurrentCultureIgnoreCase)); - if (sheetRecord?.Path.Split('/')[^1] is not { } sheetFile) + if (sheetRecord?.Path?.Split('/')[^1] is not { } sheetFile) throw new InvalidDataException($"There is no sheet named {sheetName}"); List people = []; @@ -1186,7 +1186,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance .ToList() ?? []; } - if (Archive.EntryCollection.SingleOrDefault(x => x.FullName == $"xl/worksheets/_rels/{sheetFile}.rels") is not { } rel) + if (Archive.GetEntry($"xl/worksheets/_rels/{sheetFile}.rels") is not { } rel) return new CommentResultSet(sheetName, [], []); #if NET10_0_OR_GREATER @@ -1217,7 +1217,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance List commentThreads = []; List notes = []; - string[] refCells = []; + HashSet refCells = []; if (Archive.GetEntry($"xl/{threadedCommentsPath}") is { } threadEntry) { #if NET10_0_OR_GREATER @@ -1243,7 +1243,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance { Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), Author = people.FirstOrDefault(p => p.Id == (Guid.TryParse(tc.Attribute("personId")?.Value, out var person) ? person : Guid.Empty)), - CreationTime = DateTime.Parse(tc.Attribute("dT")!.Value), + CreationTime = DateTime.Parse(tc.Attribute("dT")!.Value, CultureInfo.InvariantCulture), ReferenceCell = tc.Attribute("ref")?.Value!, FirstMessage = tc.Value, Resolved = tc.Attribute("done")?.Value is not (null or "0") @@ -1258,7 +1258,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), ParentId = Guid.Parse(tc.Attribute("parentId")!.Value), Author = people.FirstOrDefault(p => p.Id == Guid.Parse(tc.Attribute("personId")!.Value)), - ReplyTime = DateTime.Parse(tc.Attribute("dT")!.Value), + ReplyTime = DateTime.Parse(tc.Attribute("dT")!.Value, CultureInfo.InvariantCulture), ReplyText = tc.Value }) .ToLookup(x => x.ParentId); @@ -1271,7 +1271,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance } } - refCells = [..commentThreads.Select(x => x.ReferenceCell).Distinct()]; + refCells = [..commentThreads.Select(x => x.ReferenceCell)]; } if (Archive.GetEntry($"xl/{notesPath}") is { } noteEntry) @@ -1303,8 +1303,8 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance ?.Where(c => !refCells.Contains(c.Attribute("ref")?.Value)) .Select(c => new NoteComment { - Id = Guid.Parse(c.Attribute(ns14R + "uid")!.Value.Trim('{', '}')), - Author = authors?.ElementAtOrDefault(int.Parse(c.Attribute("authorId")!.Value)), + Id = Guid.TryParse(c.Attribute(ns14R + "uid")?.Value.Trim('{', '}'), out var noteId) ? noteId : Guid.Empty, + Author = int.TryParse(c.Attribute("authorId")?.Value, out var authorId) ? authors?.ElementAtOrDefault(authorId) : "", ReferenceCell = c.Attribute("ref")?.Value, Text = string.Join("", GetTextFromComment(c)) }) @@ -1316,7 +1316,7 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance IEnumerable GetTextFromComment(XElement? comment) { return comment?.Element(nsMain + "text") is { } textElement - ? textElement.Elements(nsMain + "r").Select(r => r.Element(nsMain + "t")?.Value) + ? textElement.Descendants(nsMain + "t").Select(t => t.Value) : []; } } diff --git a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs index 7f3c227e..817a7d86 100644 --- a/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs +++ b/src/MiniExcel.OpenXml/Picture/OpenXmlPictureImplement.cs @@ -35,7 +35,7 @@ public static async Task AddPictureAsync(Stream excelStream, CancellationToken c { var sheetName = sheetGroup.Key; var sheetEnt = sheetEntries.Find(x => x.Name == sheetName) ?? sheetEntries[0]; - var sheetXmlName = sheetEnt.Path.Split('/').Last().Split('.')[0]; + var sheetXmlName = !string.IsNullOrEmpty(sheetEnt.Path) ? sheetEnt.Path.Split('/')[^1].Split('.')[0] : null; var sheetPath = $"xl/worksheets/{sheetXmlName}.xml"; var sheetEntry = archive.GetEntry(sheetPath); From ea3c9c3ce44fb005999bdb6e56e1b1ba69470f23 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Apr 2026 16:05:32 +0200 Subject: [PATCH 09/11] Changed some properties' names --- src/MiniExcel.OpenXml/Models/Comments.cs | 8 +++--- src/MiniExcel.OpenXml/OpenXmlReader.cs | 8 +++--- .../Comments/CommentsRetrievalAsyncTests.cs | 28 +++++++++---------- .../Comments/CommentsRetrievalTests.cs | 28 +++++++++---------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/MiniExcel.OpenXml/Models/Comments.cs b/src/MiniExcel.OpenXml/Models/Comments.cs index f4d8ad53..37516727 100644 --- a/src/MiniExcel.OpenXml/Models/Comments.cs +++ b/src/MiniExcel.OpenXml/Models/Comments.cs @@ -20,8 +20,8 @@ public class ThreadedComment public string ReferenceCell { get; internal set; } = null!; public Author? Author { get; internal set; } public bool Resolved { get; internal set; } - public string? FirstMessage { get; internal set; } - public DateTime CreationTime { get; internal set; } + public string? Text { get; internal set; } + public DateTime CreatedAt { get; internal set; } internal List ThreadedComments = []; public IReadOnlyList Replies => ThreadedComments; @@ -32,8 +32,8 @@ public class ThreadedCommentReply public Guid Id { get; internal set; } public Guid? ParentId { get; internal set; } public Author? Author { get; internal set; } - public DateTime ReplyTime { get; internal set; } - public string? ReplyText { get; internal set; } + public DateTime CreatedAt { get; internal set; } + public string? Text { get; internal set; } } public class NoteComment diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index a32c8c4e..2313cc6a 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -1243,9 +1243,9 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance { Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), Author = people.FirstOrDefault(p => p.Id == (Guid.TryParse(tc.Attribute("personId")?.Value, out var person) ? person : Guid.Empty)), - CreationTime = DateTime.Parse(tc.Attribute("dT")!.Value, CultureInfo.InvariantCulture), + CreatedAt = DateTime.Parse(tc.Attribute("dT")!.Value, CultureInfo.InvariantCulture), ReferenceCell = tc.Attribute("ref")?.Value!, - FirstMessage = tc.Value, + Text = tc.Value, Resolved = tc.Attribute("done")?.Value is not (null or "0") }) .ToList() ?? []; @@ -1258,8 +1258,8 @@ internal async Task ReadCommentsAsync(string? sheetName, Cance Id = Guid.Parse(tc.Attribute("id")!.Value.Trim('{', '}')), ParentId = Guid.Parse(tc.Attribute("parentId")!.Value), Author = people.FirstOrDefault(p => p.Id == Guid.Parse(tc.Attribute("personId")!.Value)), - ReplyTime = DateTime.Parse(tc.Attribute("dT")!.Value, CultureInfo.InvariantCulture), - ReplyText = tc.Value + CreatedAt = DateTime.Parse(tc.Attribute("dT")!.Value, CultureInfo.InvariantCulture), + Text = tc.Value }) .ToLookup(x => x.ParentId); diff --git a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs index 481cf9eb..bcbacf9e 100644 --- a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalAsyncTests.cs @@ -16,9 +16,9 @@ public async Task SheetWithCommentsAndNotesTestAsync() Assert.Equal(2, commentSet.Comments.Count); Assert.Equal("B3", firstComment.ReferenceCell); - Assert.Equal(new DateTime(2026, 3, 21, 12, 7, 24), firstComment.CreationTime); + Assert.Equal(new DateTime(2026, 3, 21, 12, 7, 24), firstComment.CreatedAt); Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Id); - Assert.Equal("this is a comment", firstComment.FirstMessage); + Assert.Equal("this is a comment", firstComment.Text); Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), firstComment.Author?.Id); Assert.Equal("John Doe", firstComment.Author?.DisplayName); Assert.Equal("google-sheets", firstComment.Author?.ProviderId); @@ -27,25 +27,25 @@ public async Task SheetWithCommentsAndNotesTestAsync() Assert.Equal(new Guid("dfb1d4cd-7f1f-42ae-9f61-330f03f0b9ad"), firstComment.Replies[0].Id); Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[0].ParentId); - Assert.Equal(new DateTime(2026, 3, 21, 21, 17, 45), firstComment.Replies[0].ReplyTime); + Assert.Equal(new DateTime(2026, 3, 21, 21, 17, 45), firstComment.Replies[0].CreatedAt); Assert.Equal("Mary Sue", firstComment.Replies[0].Author?.DisplayName); - Assert.Equal("this is a reply", firstComment.Replies[0].ReplyText); + Assert.Equal("this is a reply", firstComment.Replies[0].Text); Assert.Equal(new Guid("d99bde2c-3df5-4300-a12e-2cc3b831c5dd"), firstComment.Replies[1].Id); Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[1].ParentId); - Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 3), firstComment.Replies[1].ReplyTime); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 3), firstComment.Replies[1].CreatedAt); Assert.Equal("John Doe", firstComment.Replies[1].Author?.DisplayName); - Assert.Equal("this is another reply", firstComment.Replies[1].ReplyText); + Assert.Equal("this is another reply", firstComment.Replies[1].Text); Assert.Empty(secondComment.Replies); Assert.Equal("E2", secondComment.ReferenceCell); Assert.Equal(new Guid("0fdf4b1e-0d47-4717-9dd5-c9fc731b0ad6"), secondComment.Id); - Assert.Equal(new DateTime(2026, 3, 21, 21, 35, 17), secondComment.CreationTime); + Assert.Equal(new DateTime(2026, 3, 21, 21, 35, 17), secondComment.CreatedAt); Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), secondComment.Author?.Id); Assert.Equal("Mary Sue", secondComment.Author?.DisplayName); Assert.Equal("AD", secondComment.Author?.ProviderId); Assert.False(secondComment.Resolved); - Assert.Equal("this is a separate comment", secondComment.FirstMessage); + Assert.Equal("this is a separate comment", secondComment.Text); Assert.Equal(2, commentSet.Notes.Count); var (firstNote, secondNote) = (commentSet.Notes[0], commentSet.Notes[1]); @@ -72,12 +72,12 @@ public async Task SheetWithNotesAndCommentsWithoutRepliesTestAsync() Assert.Empty(comment.Replies); Assert.Equal("A3", comment.ReferenceCell); Assert.Equal(new Guid("597d85de-079d-4129-8ebb-e6a9666c1c31"), comment.Id); - Assert.Equal(new DateTime(2026, 3, 21, 12, 8, 22), comment.CreationTime); + Assert.Equal(new DateTime(2026, 3, 21, 12, 8, 22), comment.CreatedAt); Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); Assert.Equal("John Doe", comment.Author?.DisplayName); Assert.Equal("google-sheets", comment.Author?.ProviderId); Assert.False(comment.Resolved); - Assert.Equal("this is a comment on another sheet", comment.FirstMessage); + Assert.Equal("this is a comment on another sheet", comment.Text); Assert.Single(commentSet.Notes); var note = commentSet.Notes[0]; @@ -106,9 +106,9 @@ public async Task SheetWithResolvedThreadedCommentsTestAsync() Assert.Single(commentSet.Comments); Assert.Equal("sheet4", commentSet.SheetName, ignoreCase: true); Assert.Equal("D2", comment.ReferenceCell); - Assert.Equal(new DateTime(2026, 3, 21, 12, 34, 24), comment.CreationTime); + Assert.Equal(new DateTime(2026, 3, 21, 12, 34, 24), comment.CreatedAt); Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Id); - Assert.Equal("this thread will be resolved", comment.FirstMessage); + Assert.Equal("this thread will be resolved", comment.Text); Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); Assert.Equal("John Doe", comment.Author?.DisplayName); Assert.Equal("google-sheets", comment.Author?.ProviderId); @@ -117,10 +117,10 @@ public async Task SheetWithResolvedThreadedCommentsTestAsync() Assert.Equal(new Guid("f4863ec3-3a84-453a-88a7-ed634a96dd18"), comment.Replies[0].Id); Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Replies[0].ParentId); - Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 55), comment.Replies[0].ReplyTime); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 55), comment.Replies[0].CreatedAt); Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), comment.Replies[0].Author?.Id); Assert.Equal("Mary Sue", comment.Replies[0].Author?.DisplayName); Assert.Equal("AD", comment.Replies[0].Author?.ProviderId); - Assert.Equal("ok", comment.Replies[0].ReplyText); + Assert.Equal("ok", comment.Replies[0].Text); } } diff --git a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs index b3a0e8ea..f4162278 100644 --- a/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Comments/CommentsRetrievalTests.cs @@ -16,9 +16,9 @@ public void SheetWithNotesAndCommentsWithRepliesTest() Assert.Equal(2, commentSet.Comments.Count); Assert.Equal("B3", firstComment.ReferenceCell); - Assert.Equal(new DateTime(2026, 3, 21, 12, 7, 24), firstComment.CreationTime); + Assert.Equal(new DateTime(2026, 3, 21, 12, 7, 24), firstComment.CreatedAt); Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Id); - Assert.Equal("this is a comment", firstComment.FirstMessage); + Assert.Equal("this is a comment", firstComment.Text); Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), firstComment.Author?.Id); Assert.Equal("John Doe", firstComment.Author?.DisplayName); Assert.Equal("google-sheets", firstComment.Author?.ProviderId); @@ -27,25 +27,25 @@ public void SheetWithNotesAndCommentsWithRepliesTest() Assert.Equal(new Guid("dfb1d4cd-7f1f-42ae-9f61-330f03f0b9ad"), firstComment.Replies[0].Id); Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[0].ParentId); - Assert.Equal(new DateTime(2026, 3, 21, 21, 17, 45), firstComment.Replies[0].ReplyTime); + Assert.Equal(new DateTime(2026, 3, 21, 21, 17, 45), firstComment.Replies[0].CreatedAt); Assert.Equal("Mary Sue", firstComment.Replies[0].Author?.DisplayName); - Assert.Equal("this is a reply", firstComment.Replies[0].ReplyText); + Assert.Equal("this is a reply", firstComment.Replies[0].Text); Assert.Equal(new Guid("d99bde2c-3df5-4300-a12e-2cc3b831c5dd"), firstComment.Replies[1].Id); Assert.Equal(new Guid("8d44beaf-9259-4d6a-8559-58427a76727b"), firstComment.Replies[1].ParentId); - Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 3), firstComment.Replies[1].ReplyTime); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 3), firstComment.Replies[1].CreatedAt); Assert.Equal("John Doe", firstComment.Replies[1].Author?.DisplayName); - Assert.Equal("this is another reply", firstComment.Replies[1].ReplyText); + Assert.Equal("this is another reply", firstComment.Replies[1].Text); Assert.Empty(secondComment.Replies); Assert.Equal("E2", secondComment.ReferenceCell); Assert.Equal(new Guid("0fdf4b1e-0d47-4717-9dd5-c9fc731b0ad6"), secondComment.Id); - Assert.Equal(new DateTime(2026, 3, 21, 21, 35, 17), secondComment.CreationTime); + Assert.Equal(new DateTime(2026, 3, 21, 21, 35, 17), secondComment.CreatedAt); Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), secondComment.Author?.Id); Assert.Equal("Mary Sue", secondComment.Author?.DisplayName); Assert.Equal("AD", secondComment.Author?.ProviderId); Assert.False(secondComment.Resolved); - Assert.Equal("this is a separate comment", secondComment.FirstMessage); + Assert.Equal("this is a separate comment", secondComment.Text); Assert.Equal(2, commentSet.Notes.Count); @@ -71,12 +71,12 @@ public void SheetWithNotesAndCommentsWithoutRepliesTest() Assert.Empty(comment.Replies); Assert.Equal("A3", comment.ReferenceCell); Assert.Equal(new Guid("597d85de-079d-4129-8ebb-e6a9666c1c31"), comment.Id); - Assert.Equal(new DateTime(2026, 3, 21, 12, 8, 22), comment.CreationTime); + Assert.Equal(new DateTime(2026, 3, 21, 12, 8, 22), comment.CreatedAt); Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); Assert.Equal("John Doe", comment.Author?.DisplayName); Assert.Equal("google-sheets", comment.Author?.ProviderId); Assert.False(comment.Resolved); - Assert.Equal("this is a comment on another sheet", comment.FirstMessage); + Assert.Equal("this is a comment on another sheet", comment.Text); Assert.Single(commentSet.Notes); var note = commentSet.Notes[0]; @@ -105,9 +105,9 @@ public void SheetWithResolvedThreadedCommentsTest() Assert.Single(commentSet.Comments); Assert.Equal("sheet4", commentSet.SheetName, ignoreCase: true); Assert.Equal("D2", comment.ReferenceCell); - Assert.Equal(new DateTime(2026, 3, 21, 12, 34, 24), comment.CreationTime); + Assert.Equal(new DateTime(2026, 3, 21, 12, 34, 24), comment.CreatedAt); Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Id); - Assert.Equal("this thread will be resolved", comment.FirstMessage); + Assert.Equal("this thread will be resolved", comment.Text); Assert.Equal(new Guid("cb8b42e9-e059-4d6b-b054-b1437d6cf7cd"), comment.Author?.Id); Assert.Equal("John Doe", comment.Author?.DisplayName); Assert.Equal("google-sheets", comment.Author?.ProviderId); @@ -116,10 +116,10 @@ public void SheetWithResolvedThreadedCommentsTest() Assert.Equal(new Guid("f4863ec3-3a84-453a-88a7-ed634a96dd18"), comment.Replies[0].Id); Assert.Equal(new Guid("cc210736-0fae-4525-aa57-df776a5548fa"), comment.Replies[0].ParentId); - Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 55), comment.Replies[0].ReplyTime); + Assert.Equal(new DateTime(2026, 3, 21, 21, 20, 55), comment.Replies[0].CreatedAt); Assert.Equal(new Guid("eaf7fda0-61e5-4210-9faa-da7028ea718a"), comment.Replies[0].Author?.Id); Assert.Equal("Mary Sue", comment.Replies[0].Author?.DisplayName); Assert.Equal("AD", comment.Replies[0].Author?.ProviderId); - Assert.Equal("ok", comment.Replies[0].ReplyText); + Assert.Equal("ok", comment.Replies[0].Text); } } From 1b6170483a05faafb6de9492b1749d85ca4f2920 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Apr 2026 16:17:12 +0200 Subject: [PATCH 10/11] Updated documentation --- README-V2.md | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/README-V2.md b/README-V2.md index 89229e2e..3f77c5af 100644 --- a/README-V2.md +++ b/README-V2.md @@ -342,7 +342,7 @@ var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); var sheetNames = excelImporter.GetSheetNames(path); ``` -#### 6. Get the columns' names from an Excel sheet +#### 6. Get the columns' names from an Excel worksheet ```csharp var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); @@ -350,9 +350,39 @@ var columns = excelImporter.GetColumnNames(path); // columns = [ColumnName1, ColumnName2, ...] when there is a header row // columns = ["A","B",...] otherwise + +``` +#### 7. Retrieve Comments from an Excel worksheet + +You can extract threaded comments and their replies from a worksheet using the `RetrieveComments` method: + +```csharp +var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); +var comments = excelImporter.RetrieveComments(path, sheetName: "Sheet1").Comments; + +foreach (var comment in comments) +{ + Console.WriteLine($"Cell: {comment.CellAddress}"); + Console.WriteLine($"{comment.CreatedAt:yy-MM-dd HH:mm}, {comment.Author.DisplayName}: {comment.Text}"); + + foreach (var reply in comment.Replies) + { + Console.WriteLine($"{reply.CreatedAt:yy-MM-dd HH:mm}, {reply.Author.DisplayName}: {reply.Text}"); + } +} +``` + +You can similarly retrieve notes as well: +```csharp +var notes = excelImporter.RetrieveComments(path, sheetName: "Sheet1").Notes; +foreach (var note in notes) +{ + Console.WriteLine($"Cell: {note.CellAddress}"); + Console.WriteLine($"{note.Author.DisplayName}: {note.Text}"); +} ``` -#### 7. Casting dynamic rows to IDictionary +#### 8. Casting dynamic rows to IDictionary Under the hood the dynamic objects returned in a query are implemented using `ExpandoObject`, making it possible to cast them to `IDictionary`: @@ -370,7 +400,7 @@ foreach(IDictionary row in excelImporter.Query(path)) } ``` -#### 8. Query Excel sheet as a DataTable +#### 9. Query Excel worksheet as a DataTable This is not recommended, as `DataTable` will forcibly load all data into memory, effectively losing the advantages MiniExcel offers. @@ -379,7 +409,7 @@ var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); var table = excelImporter.QueryAsDataTable(path); ``` -#### 9. Specify what cell to start reading data from +#### 10. Specify what cell to start reading data from ```csharp var excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); @@ -387,7 +417,7 @@ excelImporter.Query(path, startCell: "B3") ``` ![image](https://user-images.githubusercontent.com/12729184/117260316-8593c400-ae81-11eb-9877-c087b7ac2b01.png) -#### 10. Fill Merged Cells +#### 11. Fill Merged Cells If the Excel sheet being queried contains merged cells it is possble to enable the option to fill every row with the merged value. @@ -410,7 +440,7 @@ Filling of cells with variable width and height is also supported >Note: The performance will take a hit when enabling the feature. >This happens because in the OpenXml standard the `mergeCells` are indicated at the bottom of the file, which leads to the need of reading the whole sheet twice. -#### 11. Big files and disk-based cache +#### 12. Big files and disk-based cache If the SharedStrings file size exceeds 5 MB, MiniExcel will default to use a local disk cache. E.g: on the file [10x100000.xlsx](https://github.com/MiniExcel/MiniExcel/files/8403819/NotDuplicateSharedStrings_10x100000.xlsx) (one million rows of data), when disabling the disk cache the maximum memory usage is 195 MB, but with disk cache enabled only 65 MB of memory are used. From b5ca3217cdcdc8f01d5b46a3de57f450365b0477 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Sat, 4 Apr 2026 16:47:09 +0200 Subject: [PATCH 11/11] Added ConfiguredAsyncDisposable calls to OpenXmlExporter and OpenXmlTemplater --- src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs | 12 ++++++++- src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs index ed8a8810..7951ca28 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs @@ -18,7 +18,12 @@ public async Task InsertSheetAsync(string path, object value, string? sheet return rowsWritten.FirstOrDefault(); } +#if NET8_0_OR_GREATER + var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read, 4096, FileOptions.SequentialScan); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read, 4096, FileOptions.SequentialScan); +#endif return await InsertSheetAsync(stream, value, sheetName, printHeader, overwriteSheet, configuration, progress, cancellationToken).ConfigureAwait(false); } @@ -46,8 +51,13 @@ public async Task ExportAsync(string path, object value, bool printHeader throw new NotSupportedException("MiniExcel's Export does not support the .xlsm format"); var filePath = path.EndsWith(".xlsx", StringComparison.InvariantCultureIgnoreCase) ? path : $"{path}.xlsx" ; - + +#if NET8_0_OR_GREATER + var stream = overwriteFile ? File.Create(filePath) : new FileStream(filePath, FileMode.CreateNew); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = overwriteFile ? File.Create(filePath) : new FileStream(filePath, FileMode.CreateNew); +#endif return await ExportAsync(stream, value, printHeader, sheetName, configuration, progress, cancellationToken).ConfigureAwait(false); } diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs b/src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs index 9b4e2aca..e6fcc435 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs @@ -13,7 +13,12 @@ internal OpenXmlTemplater() { } [CreateSyncVersion] public async Task AddPictureAsync(string path, CancellationToken cancellationToken = default, params MiniExcelPicture[] images) { +#if NET8_0_OR_GREATER + var stream = File.Open(path, FileMode.OpenOrCreate); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = File.Open(path, FileMode.OpenOrCreate); +#endif await MiniExcelPictureImplement.AddPictureAsync(stream, cancellationToken, images).ConfigureAwait(false); } @@ -27,7 +32,12 @@ public async Task AddPictureAsync(Stream excelStream, CancellationToken cancella public async Task FillTemplateAsync(string path, string templatePath, object value, bool overwriteFile = false, OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); +#endif await FillTemplateAsync(stream, templatePath, value, configuration, cancellationToken).ConfigureAwait(false); } @@ -35,7 +45,13 @@ public async Task FillTemplateAsync(string path, string templatePath, object val public async Task FillTemplateAsync(string path, Stream templateStream, object value, bool overwriteFile = false, OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); +#endif + var template = GetOpenXmlTemplate(stream, configuration); await template.SaveAsByTemplateAsync(templateStream, value, cancellationToken).ConfigureAwait(false); } @@ -60,7 +76,12 @@ public async Task FillTemplateAsync(Stream stream, Stream templateStream, object public async Task FillTemplateAsync(string path, byte[] templateBytes, object value, bool overwriteFile = false, OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = overwriteFile ? File.Create(path) : File.Open(path, FileMode.CreateNew); +#endif await FillTemplateAsync(stream, templateBytes, value, configuration, cancellationToken).ConfigureAwait(false); } @@ -78,7 +99,12 @@ public async Task FillTemplateAsync(Stream stream, byte[] templateBytes, object public async Task MergeSameCellsAsync(string mergedFilePath, string path, OpenXmlConfiguration? configuration = null, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + var stream = File.Create(mergedFilePath); + await using var disposableStream = stream.ConfigureAwait(false); +#else using var stream = File.Create(mergedFilePath); +#endif await MergeSameCellsAsync(stream, path, configuration, cancellationToken).ConfigureAwait(false); }