diff --git a/src/MiniExcel.Core/Helpers/Polyfills.cs b/src/MiniExcel.Core/Helpers/Polyfills.cs index 47ad6be6..ecc97456 100644 --- a/src/MiniExcel.Core/Helpers/Polyfills.cs +++ b/src/MiniExcel.Core/Helpers/Polyfills.cs @@ -156,3 +156,18 @@ public static ValueTask CreateAsync(Stream stream, ZipArchiveMode mo } #endif } + +#if NETSTANDARD2_0 +/// +/// Custom equality comparer that uses reference equality instead of overridden object.Equals. +/// Required for .NET versions where ReferenceEqualityComparer is not built-in. +/// +public class ReferenceEqualityComparer : IEqualityComparer +{ + private ReferenceEqualityComparer() { } + public static ReferenceEqualityComparer Instance { get; } = new(); + + bool IEqualityComparer.Equals(object? x, object? y) => ReferenceEquals(x, y); + int IEqualityComparer.GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); +} +#endif diff --git a/src/MiniExcel.Core/Helpers/TypeHelper.cs b/src/MiniExcel.Core/Helpers/TypeHelper.cs index 2e507a93..6349f7c4 100644 --- a/src/MiniExcel.Core/Helpers/TypeHelper.cs +++ b/src/MiniExcel.Core/Helpers/TypeHelper.cs @@ -23,8 +23,11 @@ public static IEnumerable> ToEnumerableDictionaries( .Select(t => t.GetGenericArguments()[0]); } - public static bool IsNumericType(Type type, bool isNullableUnderlyingType = false) + public static bool IsNumericType(Type? type, bool isNullableUnderlyingType = false) { + if (type is null) + return false; + if (isNullableUnderlyingType) type = Nullable.GetUnderlyingType(type) ?? type; diff --git a/src/MiniExcel.OpenXml/Constants/Schemas.cs b/src/MiniExcel.OpenXml/Constants/Schemas.cs index 622b5eea..3cdb45ec 100644 --- a/src/MiniExcel.OpenXml/Constants/Schemas.cs +++ b/src/MiniExcel.OpenXml/Constants/Schemas.cs @@ -11,6 +11,7 @@ internal static class Schemas public const string SpreadsheetmlXmlSpreadsheetDrawing = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"; public const string SpreadsheetmlXmlDrawingml2006 = "http://schemas.openxmlformats.org/drawingml/2006/main"; public const string SpreadsheetmlXmlDrawing2014 = "http://schemas.microsoft.com/office/drawing/2014/main"; + public const string SpreadsheetmlXmlWorksheetRelationship = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"; public const string SpreadsheetmlXmlDrawingRelationship = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"; public const string SpreadsheetmlXmlImageRelationship = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; public const string SpreadsheetmlXmlTableRelationship = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table"; diff --git a/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs b/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs index 20babe76..b6f15f4d 100644 --- a/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs +++ b/src/MiniExcel.OpenXml/OpenXmlConfiguration.cs @@ -39,6 +39,11 @@ public class OpenXmlConfiguration : MiniExcelBaseConfiguration public bool EnableAutoWidth { get; set; } public double MinWidth { get; set; } = 8.42857143; public double MaxWidth { get; set; } = 200; + + /// + /// This option sets the maximum level of nesting a property in a model passed to the is allowed to have + /// + public int RecursivePropertiesMaxDepth { get; set; } = 4; } public enum TableStyles diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs index 367704b2..09009ffe 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs @@ -640,46 +640,16 @@ private async Task GenerateCellValuesAsync( ? prop.Value.UnderlyingMemberType : Nullable.GetUnderlyingType(propInfo.PropertyType) ?? propInfo.PropertyType; - string? cellValueStr; - if (type == typeof(bool)) - { - cellValueStr = (bool)cellValue ? "1" : "0"; - } - else if (type == typeof(DateTime)) - { - cellValueStr = ConvertToDateTimeString(propInfo, cellValue); - } - else if (type?.IsEnum is true) - { - var stringValue = Enum.GetName(type, cellValue) ?? ""; - - var attr = type.GetField(stringValue)?.GetCustomAttribute(); - var description = attr?.Description ?? stringValue; - - cellValueStr = XmlHelper.EncodeXml(description); - } - else - { - cellValueStr = XmlHelper.EncodeXml(cellValue?.ToString()); - if (TypeHelper.IsNumericType(type)) - { - if (decimal.TryParse(cellValueStr, out var decimalValue)) - cellValueStr = decimalValue.ToString(CultureInfo.InvariantCulture); - } - } - - // escaping formulas - var tempReplacement = cellValueStr ?? ""; - var replacementValue = tempReplacement.StartsWith("$=") || tempReplacement.StartsWith("=") - ? $"'{tempReplacement}" - : tempReplacement; + var replacementValue = GetFormattedValue(propInfo, cellValue, type); replacements[key] = replacementValue; + FlattenAndFormatValues(replacements, key, cellValue, _configuration.RecursivePropertiesMaxDepth, propInfo); + rowXml.Replace($"@header{{{{{key}}}}}", replacementValue); if (isHeaderRow && row.Value.Contains(key)) { - currentHeader += cellValueStr; + currentHeader += replacementValue; } } @@ -784,6 +754,47 @@ private async Task GenerateCellValuesAsync( }; } + /// + /// Formats the given cell value into a string representation suitable for OpenXml injection. + /// Handles specific types like booleans, dates, enums, and numeric values. + /// + private static string GetFormattedValue(PropertyInfo? propInfo, object? cellValue, Type? type) + { + string? cellValueStr; + if (type == typeof(bool)) + { + cellValueStr = (bool)cellValue! ? "1" : "0"; + } + else if (type == typeof(DateTime)) + { + cellValueStr = ConvertToDateTimeString(propInfo, cellValue); + } + else if (type?.IsEnum is true) + { + // Use the DescriptionAttribute value if it exists, otherwise fallback to the enum string name. + var stringValue = Enum.GetName(type, cellValue!) ?? ""; + var attr = type.GetField(stringValue)?.GetCustomAttribute(); + var description = attr?.Description ?? stringValue; + + // Encode the final string to ensure it is safe for XML. + cellValueStr = XmlHelper.EncodeXml(description); + } + else + { + cellValueStr = XmlHelper.EncodeXml(cellValue?.ToString()); + if (TypeHelper.IsNumericType(type) && decimal.TryParse(cellValueStr, out var decimalValue)) + { + cellValueStr = decimalValue.ToString(CultureInfo.InvariantCulture); + } + } + + // escaping formulas + var tempReplacement = cellValueStr ?? ""; + return tempReplacement.StartsWith("$=") || tempReplacement.StartsWith("=") + ? $"'{tempReplacement}" + : tempReplacement; + } + private static void MergeCells(List xRowInfos) { var mergeTaggedColumns = new Dictionary(); @@ -938,7 +949,7 @@ private void ProcessFormulas(StringBuilder rowXml, int rowIndex) rowXml.Append(rowElement.FirstNode); } - private static string? ConvertToDateTimeString(PropertyInfo? propInfo, object cellValue) + private static string? ConvertToDateTimeString(PropertyInfo? propInfo, object? cellValue) { //TODO:c.SetAttribute("t", "d"); and custom format var format = propInfo?.GetAttributeValue((MiniExcelFormatAttribute x) => x.Format) @@ -1204,8 +1215,38 @@ private void UpdateDimensionAndGetRowsInfo(IDictionary inputMap v?.Value = v.Value.Replace($"{{{{{propNames[0]}.{propNames[1]}}}}}", ""); continue; } + // auto check type https://github.com/mini-software/MiniExcel/issues/177 - var type = prop.UnderlyingMemberType; //avoid nullable + var currentType = prop.UnderlyingMemberType; + + // If the template expression exceeds two levels down the property chain to retrieve the deepest actual type. + for (int i = 2; i < propNames.Length; i++) + { + if (currentType == null) + break; + + var searchType = Nullable.GetUnderlyingType(currentType) ?? currentType; + + // Try to find a property first + var deepProp = searchType.GetProperty(propNames[i]); + if (deepProp != null) + { + currentType = Nullable.GetUnderlyingType(deepProp.PropertyType) ?? deepProp.PropertyType; + continue; + } + + // Fallback to finding a field (for records or public fields) + if (searchType.GetField(propNames[i]) is { } deepField) + { + currentType = Nullable.GetUnderlyingType(deepField.FieldType) ?? deepField.FieldType; + continue; + } + + // Break if neither property nor field is found + currentType = null; + } + + var type = currentType; if (isMultiMatch) { diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.ValueExtractorHook.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.ValueExtractorHook.cs new file mode 100644 index 00000000..175b935b --- /dev/null +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.ValueExtractorHook.cs @@ -0,0 +1,297 @@ +namespace MiniExcelLib.OpenXml.Templates; + +internal partial class OpenXmlTemplate +{ + private static readonly XNamespace PackageRelNs = Schemas.OpenXmlPackageRelationships; + private static readonly XNamespace SpreadsheetRelNs = Schemas.SpreadsheetmlXmlRelationships; + +#if NET + [GeneratedRegex(@"\$([^$]+)\$")] private static partial Regex ParametrizedSheetRegex(); + private static readonly Regex ParametrizedSheetRegexImpl = ParametrizedSheetRegex(); +#else + private static readonly Regex ParametrizedSheetRegexImpl = new(@"\$([^$]+)\$", RegexOptions.Compiled); +#endif + + /// + /// Recursively flattens an object graph into a dictionary of "key.subkey" pairs and fully formats the values. + /// Includes protection against circular references and stack overflow via depth limiting. + /// + private static void FlattenAndFormatValues(Dictionary replacements, string key, object? value, int maxDepth, PropertyInfo? propInfo = null) + { + // Initialize a HashSet with reference equality comparer to track visited objects and prevent infinite loops from circular references. + var visited = new HashSet(ReferenceEqualityComparer.Instance); + + // Start the recursive processing with initial depth set to 0. + TraverseAndFlatten(replacements, key, value, propInfo, maxDepth, 0, visited); + return; + + // + // The internal recursive method that performs the actual object traversal, flattening, and formatting. + // + static void TraverseAndFlatten( + Dictionary replacements, + string key, + object? value, + PropertyInfo? propInfo, + int maxDepth, + int currentDepth, + HashSet visited) + { + // Handle null values or invalid types + if (value?.GetType() is not { } type) + { + replacements[key] = string.Empty; + return; + } + + // 1. Primitive types / Enums: Format directly, do not consume depth and do not enter reference tracking. + if (type.IsPrimitive || type.IsEnum || + type == typeof(string) || type == typeof(decimal) || + type == typeof(DateTime) || type == typeof(Guid) || + Nullable.GetUnderlyingType(type) != null) + { + replacements[key] = GetFormattedValue(propInfo, value, type); + return; + } + + // 2. Depth control: Safe fallback to string representation when exceeding the limit to avoid OOM/StackOverflow. + if (currentDepth >= maxDepth) + return; + + // 3. Circular reference detection (only for reference types; value types cannot form reference loops). + if (!type.IsValueType && !visited.Add(value)) + return; + + try + { + // 4. Dictionary handling: Iterate through key-value pairs and recursively process values. + if (value is Dictionary dict) + { + foreach (var (innerKey, innerValue) in dict) + { + // Construct the new sub-key by appending the dictionary key. + var subKey = string.Concat(key, ".", innerKey); + TraverseAndFlatten(replacements, subKey, innerValue, propInfo, maxDepth, currentDepth + 1, visited); + } + return; + } + + replacements[key] = GetFormattedValue(propInfo, value, type); + + // 5. Object property recursion: Get public instance properties filtering out indexers and write-only properties. + var properties = type + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0); + + foreach (var prop in properties) + { + // Construct the new sub-key by appending the property name + var subKey = string.Concat(key, ".", prop.Name); + var subValue = prop.GetValue(value); + TraverseAndFlatten(replacements, subKey, subValue, prop, maxDepth, currentDepth + 1, visited); + } + } + finally + { + // After loops (A -> B -> A) are excluded remove the current node from the visited set + // so that the same object can be accessed in different branches (A -> B, A -> C). + if (!type.IsValueType) + visited.Remove(value); + } + } + } + + /// + /// Hooks into the sheet processing pipeline to handle dynamic sheet generation based on template placeholders. + /// If a sheet name matches a specific pattern and the corresponding input value is an enumerable, + /// it generates multiple sheets based on the elements of the enumerable and returns true. + /// + [CreateSyncVersion] + private async Task<(bool IsParametrized, int NewIndex)> TryExpandParametrizedSheetAsync(OpenXmlZip outputFileArchive, string originalSheetName, IDictionary templateSharedStrings, int sheetIndex, List<(int Index, string Name)> allSheetInfos, ZipArchiveEntry templateSheet, IDictionary inputValues, CancellationToken cancellationToken = default) + { + // Use regex to match the sheet name to pattern "$PlaceholderName$" + var match = ParametrizedSheetRegexImpl.Match(originalSheetName); + + // Check if the pattern matches, the placeholder exists in input values, and the value is an IEnumerable + if (!match.Success || + !inputValues.TryGetValue(match.Groups[1].Value, out var subObj) || + subObj is not IEnumerable subIter) + { + return (false, sheetIndex); + } + + // Extract the base sheet name from the template placeholder + var baseSheetName = match.Groups[1].Value; + var indexOffset = 0; + + // 1. Batch create all worksheet files + foreach (var subRoot in subIter) + { + sheetIndex++; + indexOffset++; + + // Clear internal state collections before processing each new sheet + _xRowInfos.Clear(); + _xMergeCellInfos.Clear(); + _newXMergeCellInfos.Clear(); + _calcChainCellRefs.Clear(); + + // Extract values for the current iteration item into a dictionary + var subValues = _inputValueExtractor.ToValueDictionary(subRoot); + + // Define the internal path for the new sheet XML file + var newSheetPath = $"xl/worksheets/sheet{sheetIndex}.xml"; + + // Check if a custom "SheetName" was provided in the current item's values, or fallback base name + index + var finalSheetName = subValues.TryGetValue("SheetName", out var customSheetName) && customSheetName is not null + ? customSheetName.ToString()?.Trim() ?? $"{baseSheetName}{indexOffset}" + : $"{baseSheetName}{sheetIndex}"; + + // Only collect sheet info, do not call configuration methods yet + allSheetInfos.Add((sheetIndex, finalSheetName)); + + // Create the new worksheet entry in the output ZIP archive + var newSheetEntry = outputFileArchive.ZipFile.CreateEntry(newSheetPath); + var newSheetStream = await newSheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = newSheetStream.ConfigureAwait(false); + + // Generate the sheet content based on the template and current sub-values + await GenerateSheetByCreateModeAsync(templateSheet, newSheetStream, subValues, templateSharedStrings, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Append calculation chain content for the newly created sheet + _calcChainContent.Append(CalcChainHelper.GetCalcChainContent(_calcChainCellRefs, sheetIndex)); + } + + return (true, sheetIndex); + } + + /// + /// Adds worksheets to the workbook and register them int workbook.xml and workbook.xml.rels + /// + [CreateSyncVersion] + private static async Task BatchAddSheetsToWorkbookAsync(ZipArchive outputZip, ZipArchive templateArchive, List<(int Index, string Name)> sheetInfos, CancellationToken cancellationToken) + { + // Load the workbook and its relationships from the template + var relDoc = await LoadXmlAsync(templateArchive, ExcelFileNames.WorkbookRels, cancellationToken).ConfigureAwait(false); + var wbDoc = await LoadXmlAsync(templateArchive, ExcelFileNames.Workbook, cancellationToken).ConfigureAwait(false); + + // 1. Clear all existing elements in workbook.xml to rebuild a clean container + if (wbDoc.Root?.Element(SpreadsheetNs + "sheets") is { } sheetsPart) + { + // Directly remove child nodes, keeping the container and default namespaces + sheetsPart.Elements().Remove(); + } + else + { + // If the original template lacks a sheets node, create a new one and append it to the root + wbDoc.Root?.Add(new XElement(SpreadsheetNs + "sheets")); + } + + // 2. Clean up all relationship records pointing to worksheets in workbook.xml.rels + var relsRoot = relDoc.Root; + if (relsRoot != null) + { + // Only delete relationships of Type 'worksheet', preserving core relationships like sharedStrings/styles/theme + var worksheetRels = relsRoot.Elements(PackageRelNs + "Relationship") + .Where(r => r.Attribute("Type")?.Value == Schemas.SpreadsheetmlXmlWorksheetRelationship); + + // Remove the filtered worksheet relationships + foreach (var rel in worksheetRels) + rel.Remove(); + } + + // Batch add new relationship records for each generated sheet + foreach (var sheet in sheetInfos) + { + relDoc.Root!.Add(new XElement(PackageRelNs + "Relationship", + new XAttribute("Id", $"rIdSheet{sheet.Index}"), + new XAttribute("Type", Schemas.SpreadsheetmlXmlWorksheetRelationship), + new XAttribute("Target", $"worksheets/sheet{sheet.Index}.xml"))); + } + + // Batch add new sheet definitions to the workbook + var sheetsNode = wbDoc.Descendants(SpreadsheetNs + "sheets").FirstOrDefault(); + if (sheetsNode != null) + { + foreach (var sheet in sheetInfos) + { + sheetsNode.Add(new XElement(SpreadsheetNs + "sheet", + new XAttribute("name", sheet.Name), + new XAttribute("sheetId", sheet.Index), + new XAttribute(SpreadsheetRelNs + "id", $"rIdSheet{sheet.Index}"))); + } + } + + // Save the modified xml entries + await SaveXmlToZipAsync(outputZip, ExcelFileNames.WorkbookRels, relDoc, cancellationToken).ConfigureAwait(false); + await SaveXmlToZipAsync(outputZip, ExcelFileNames.Workbook, wbDoc, cancellationToken).ConfigureAwait(false); + } + + /// + /// Parses the template to build a mapping from each sheet name to the corresponding xml path + /// + [CreateSyncVersion] + private async Task> GetSheetNameMapAsync(ZipArchive archive, CancellationToken cancellationToken = default) + { + Dictionary nameToPath = []; + Dictionary ridToSheetPath = []; + + // 1. Read workbook.xml.rels to get the mapping of rId -> sheet path + if (await LoadXmlAsync(archive, ExcelFileNames.WorkbookRels, cancellationToken).ConfigureAwait(false) is not { } relDoc) + return []; + + foreach (var rel in relDoc.Descendants(PackageRelNs + "Relationship")) + { + if (rel.Attribute("Id")?.Value is { } rid) + { + var target = rel.Attribute("Target")?.Value; + if (string.IsNullOrEmpty(rid) || string.IsNullOrEmpty(target)) continue; + + // Construct the full internal path (ensure forward slashes for consistency) + var fullSheetPath = Path.Combine("xl", target).Replace("\\", "/"); + ridToSheetPath[rid] = fullSheetPath; + } + } + + // 2. Read workbook.xml to get the Real Sheet Name + rId mapping + if (await LoadXmlAsync(archive, ExcelFileNames.Workbook, cancellationToken).ConfigureAwait(false) is not { } wbDoc) + return []; + + foreach (var sheetNode in wbDoc.Descendants(SpreadsheetNs + "sheet")) + { + var realName = sheetNode.Attribute("name")?.Value.Trim(); + var rid = sheetNode.Attribute(SpreadsheetRelNs + "id")?.Value; + if (string.IsNullOrEmpty(realName) || string.IsNullOrEmpty(rid)) + continue; + + // If the rId exists in our temporary mapping, link the XML path to the real name + if (ridToSheetPath.TryGetValue(rid!, out var sheetPath)) + { + // key: xml path, value: real sheet name + nameToPath[sheetPath] = realName!; + } + } + + return nameToPath; + } + + [CreateSyncVersion] + private static async Task LoadXmlAsync(ZipArchive templateArchive, string path, CancellationToken cancellationToken) + { + var entry = templateArchive.GetEntry(path)!; + var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); + + return await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + private static async Task SaveXmlToZipAsync(ZipArchive outputZip, string path, XDocument doc, CancellationToken cancellationToken) + { + var newEntry = outputZip.CreateEntry(path); + var stream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); + + await doc.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs index e7d71c91..60888c6d 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -5,6 +5,13 @@ namespace MiniExcelLib.OpenXml.Templates; internal partial class OpenXmlTemplate : IMiniExcelTemplate { +#if NET + [GeneratedRegex(@"sheet\d+\.xml")] private static partial Regex WorksheetPathRegex(); + private static readonly Regex WorksheetPathRegexImpl = WorksheetPathRegex(); +#else + private static readonly Regex WorksheetPathRegexImpl = new(@"sheet\d+\.xml", RegexOptions.Compiled); +#endif + private readonly Stream _outputFileStream; private readonly OpenXmlConfiguration _configuration; private readonly OpenXmlValueExtractor _inputValueExtractor; @@ -38,14 +45,16 @@ public async Task SaveAsByTemplateAsync(byte[] templateBytes, object value, Canc [CreateSyncVersion] public async Task SaveAsByTemplateAsync(Stream templateStream, object value, CancellationToken cancellationToken = default) { - if(!templateStream.CanSeek) + if (!templateStream.CanSeek) throw new ArgumentException("The template stream must be seekable"); - + templateStream.Seek(0, SeekOrigin.Begin); - using var templateReader = await OpenXmlReader.CreateAsync(templateStream, null, cancellationToken: cancellationToken).ConfigureAwait(false); + var templateReader = await OpenXmlReader.CreateAsync(templateStream, null, cancellationToken: cancellationToken).ConfigureAwait(false); + await using var disposableTemplateReader = templateReader.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 { outputFileArchive.EntryCollection = templateReader.Archive.ZipFile.Entries; //TODO:need to remove @@ -60,7 +69,7 @@ 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 @@ -69,13 +78,17 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can #else using var originalArchive = new ZipArchive(templateStream, ZipArchiveMode.Read); #endif + // sheet name map + var sheetNamesMap = await GetSheetNameMapAsync(originalArchive, cancellationToken).ConfigureAwait(false); // Iterate through each entry in the original archive foreach (var entry in originalArchive.Entries) { var entryName = entry.FullName.TrimStart('/'); if (entryName.StartsWith(ExcelFileNames.WorksheetBase, StringComparison.OrdinalIgnoreCase) || - entryName.Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase)) + entryName.Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase) || + entryName.Equals(ExcelFileNames.Workbook, StringComparison.OrdinalIgnoreCase) || + entryName.Equals(ExcelFileNames.WorkbookRels, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -103,11 +116,12 @@ await originalEntryStream.CopyToAsync(newEntryStream //read all xlsx sheets var templateSheets = templateReader.Archive.ZipFile.Entries - .Where(entry => entry.FullName - .TrimStart('/') - .StartsWith(ExcelFileNames.WorksheetBase, StringComparison.OrdinalIgnoreCase)); + .Where(entry => entry.FullName.TrimStart('/').StartsWith(ExcelFileNames.WorksheetBase, StringComparison.OrdinalIgnoreCase)); int sheetIdx = 0; + // collect all sheet info for batch add to config, avoid duplicated and missing sheet name when create mode + List<(int Index, string Name)> allSheetInfos = []; + foreach (var templateSheet in templateSheets) { // 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 @@ -115,23 +129,35 @@ await originalEntryStream.CopyToAsync(newEntryStream _xMergeCellInfos.Clear(); _newXMergeCellInfos.Clear(); _calcChainCellRefs.Clear(); - - var templateFullName = templateSheet.FullName; + + var templateSheetPath = templateSheet.FullName; var inputValues = _inputValueExtractor.ToValueDictionary(value); - var outputZipEntry = outputFileArchive.ZipFile.CreateEntry(templateFullName); - - var outputZipSheetEntryStream = await outputZipEntry.OpenAsync(cancellationToken).ConfigureAwait(false); - await using var disposableSheetEntryStream = outputZipSheetEntryStream.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 - - sheetIdx++; - _calcChainContent.Append(CalcChainHelper.GetCalcChainContent(_calcChainCellRefs, sheetIdx)); + sheetNamesMap.TryGetValue(templateSheetPath, out var sheetName); + + if (await TryExpandParametrizedSheetAsync(outputFileArchive, sheetName, templateSharedStrings, sheetIdx, allSheetInfos, templateSheet, inputValues, cancellationToken).ConfigureAwait(false) is (true, var newIndex)) + { + sheetIdx = newIndex; + } + else + { + sheetIdx++; + var newSheetPath = WorksheetPathRegexImpl.Replace(templateSheetPath, $"sheet{sheetIdx}.xml"); + + var outputZipEntry = outputFileArchive.ZipFile.CreateEntry(newSheetPath); + var outputZipSheetEntryStream = await outputZipEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetEntryStream = outputZipSheetEntryStream.ConfigureAwait(false); + + await GenerateSheetByCreateModeAsync(templateSheet, outputZipSheetEntryStream, inputValues, templateSharedStrings, cancellationToken: cancellationToken).ConfigureAwait(false); + // disposing writer disposes streams as well, read and parse calc functions before that + + allSheetInfos.Add((sheetIdx, sheetName)); + _calcChainContent.Append(CalcChainHelper.GetCalcChainContent(_calcChainCellRefs, sheetIdx)); + } } + // batch add sheet + await BatchAddSheetsToWorkbookAsync(outputFileArchive.ZipFile, originalArchive, allSheetInfos, cancellationToken).ConfigureAwait(false); + // create mode we need to not create first then create here var calcChain = outputFileArchive.EntryCollection.FirstOrDefault(e => e.FullName.TrimStart('/').Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase)); diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs index 7c191ef8..432b2041 100644 --- a/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Issues/MiniExcelGithubIssuesTests.cs @@ -2292,15 +2292,8 @@ public void Issue606_1() }) }; - var path = Path.Combine - ( - Path.GetTempPath(), - string.Concat(nameof(MiniExcelGithubIssuesTests), "_", nameof(Issue606_1), ".xlsx") - ); - - var templateFileName = PathHelper.GetFile("xlsx/TestIssue606_Template.xlsx"); - _excelTemplater.FillTemplate(path, Path.GetFullPath(templateFileName), value); - File.Delete(path); + using var path = AutoDeletingPath.Create(); + _excelTemplater.FillTemplate(path.ToString(), PathHelper.GetFile("xlsx/TestIssue606_Template.xlsx"), value); } [Fact] diff --git a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateAsyncTests.cs index 535e858a..2b57e607 100644 --- a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateAsyncTests.cs @@ -305,16 +305,6 @@ public async Task TestGithubProject() Assert.Equal("A1:D9", dimension); } - private class TestIEnumerableTypePoco - { - public string @string { get; set; } - public int? @int { get; set; } - public decimal? @decimal { get; set; } - public double? @double { get; set; } - public DateTime? datetime { get; set; } - public bool? @bool { get; set; } - public Guid? Guid { get; set; } - } [Fact] public async Task TestIEnumerableType() { @@ -450,7 +440,7 @@ public async Task TemplateAsyncBasicTest() { var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid().ToString()}.xlsx"); - var templateBytes = File.ReadAllBytes(templatePath); + var templateBytes = await File.ReadAllBytesAsync(templatePath); // 1. By POCO var value = new { diff --git a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs index 7f96a86d..4f7ef879 100644 --- a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs @@ -8,9 +8,9 @@ namespace MiniExcelLib.OpenXml.Tests.SaveByTemplate; public class MiniExcelTemplateTests { - private readonly OpenXmlImporter _excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); - private readonly OpenXmlTemplater _excelTemplater = MiniExcel.Templaters.GetOpenXmlTemplater(); - + private readonly OpenXmlImporter _excelImporter = MiniExcel.Importers.GetOpenXmlImporter(); + private readonly OpenXmlTemplater _excelTemplater = MiniExcel.Templaters.GetOpenXmlTemplater(); + [Fact] public void TestImageType() { @@ -20,9 +20,9 @@ public void TestImageType() using var path = AutoDeletingPath.Create(); File.Copy(absolutePath, path.FilePath, overwrite: true); // Copy the template file - var img1Bytes= File.ReadAllBytes(PathHelper.GetFile("xlsx/Issue327/TestIssue327.png")); - var img2Bytes= File.ReadAllBytes(PathHelper.GetFile("xlsx/Issue327/TestIssue327.png")); - var img3Bytes= File.ReadAllBytes(PathHelper.GetFile("xlsx/Issue327/TestIssue327.png")); + var img1Bytes = File.ReadAllBytes(PathHelper.GetFile("xlsx/Issue327/TestIssue327.png")); + var img2Bytes = File.ReadAllBytes(PathHelper.GetFile("xlsx/Issue327/TestIssue327.png")); + var img3Bytes = File.ReadAllBytes(PathHelper.GetFile("xlsx/Issue327/TestIssue327.png")); var pictures = new[] { @@ -66,7 +66,7 @@ public void TestImageType() // Assert (use EPPlus to verify that images are inserted correctly) using var package = new ExcelPackage(new FileInfo(path.FilePath)); - + var sheet = package.Workbook.Worksheets[0]; var picB2 = sheet.Drawings .OfType() @@ -83,7 +83,7 @@ public void TestImageType() var picD4 = sheet.Drawings .OfType() .FirstOrDefault(p => p is { EditAs: eEditAs.TwoCell, From: { Column: 3, Row: 3 } }); - + Assert.NotNull(picD4); //Console.WriteLine("✅ Image inserted successfully (D4 - TwoCellAnchor)"); @@ -95,7 +95,7 @@ public void TestImageType() Assert.NotNull(picF6); //Console.WriteLine("✅ Image inserted successfully (F6 - OneCellAnchor)"); } - + [Fact] public void DatatableTemptyRowTest() { @@ -106,11 +106,11 @@ public void DatatableTemptyRowTest() var managers = new DataTable(); managers.Columns.Add("name"); managers.Columns.Add("department"); - + var employees = new DataTable(); employees.Columns.Add("name"); employees.Columns.Add("department"); - + var value = new Dictionary { ["title"] = "FooCompany", @@ -118,24 +118,24 @@ public void DatatableTemptyRowTest() ["employees"] = employees }; _excelTemplater.FillTemplate(path.ToString(), templatePath, value); - + var rows = _excelImporter.Query(path.ToString()).ToList(); var dimension = SheetHelper.GetFirstSheetDimensionRefValue(path.ToString()); Assert.Equal("A1:C5", dimension); } { using var path = AutoDeletingPath.Create(); - + var managers = new DataTable(); managers.Columns.Add("name"); managers.Columns.Add("department"); managers.Rows.Add("Jack", "HR"); - + var employees = new DataTable(); employees.Columns.Add("name"); employees.Columns.Add("department"); employees.Rows.Add("Wade", "HR"); - + var value = new Dictionary() { ["title"] = "FooCompany", @@ -143,7 +143,7 @@ public void DatatableTemptyRowTest() ["employees"] = employees }; _excelTemplater.FillTemplate(path.ToString(), templatePath, value); - + var rows = _excelImporter.Query(path.ToString()).ToList(); var dimension = SheetHelper.GetFirstSheetDimensionRefValue(path.ToString()); Assert.Equal("A1:C5", dimension); @@ -162,7 +162,7 @@ public void DatatableTest() managers.Columns.Add("department"); managers.Rows.Add("Jack", "HR"); managers.Rows.Add("Loan", "IT"); - + var employees = new DataTable(); employees.Columns.Add("name"); employees.Columns.Add("department"); @@ -170,7 +170,7 @@ public void DatatableTest() employees.Rows.Add("Felix", "HR"); employees.Rows.Add("Eric", "IT"); employees.Rows.Add("Keaton", "IT"); - + var value = new Dictionary() { ["title"] = "FooCompany", @@ -370,12 +370,6 @@ public void DictionaryTemplateTest() } } - private class Employee - { - public string name { get; set; } - public string department { get; set; } - } - [Fact] public void GroupTemplateTest() { @@ -475,17 +469,6 @@ public void TestGithubProject() Assert.Equal("A1:D9", dimension); } - private class TestIEnumerableTypePoco - { - public string @string { get; set; } - public int? @int { get; set; } - public decimal? @decimal { get; set; } - public double? @double { get; set; } - public DateTime? datetime { get; set; } - public bool? @bool { get; set; } - public Guid? Guid { get; set; } - } - [Fact] public void TestIEnumerableType() { @@ -576,12 +559,12 @@ public void TestTemplateTypeMapping() //1. By POCO var value = new TestIEnumerableTypePoco { - @string = "string", + @string = "string", @int = 123, @decimal = 123.45m, - @double = 123.33, + @double = 123.33, datetime = new DateTime(2021, 4, 1), - @bool = true, + @bool = true, Guid = Guid.NewGuid() }; _excelTemplater.FillTemplate(path.ToString(), templatePath, value); @@ -618,7 +601,7 @@ public void TemplateBasicTest() var templatePath = PathHelper.GetFile("xlsx/TestTemplateEasyFill.xlsx"); { using var path = AutoDeletingPath.Create(); - + // 1. By POCO var value = new { @@ -667,7 +650,7 @@ public void TemplateBasicTest() { var path = AutoDeletingPath.Create(); var templateBytes = File.ReadAllBytes(templatePath); - + // 1. By POCO var value = new { @@ -694,7 +677,7 @@ public void TemplateBasicTest() { using var path = AutoDeletingPath.Create(); - + // 2. By Dictionary var value = new Dictionary { @@ -964,4 +947,108 @@ public void TestMergeSameCellsWithLimitTag() Assert.Equal("C3:C6", mergedCells[1]); Assert.Equal("A5:A6", mergedCells[2]); } -} \ No newline at end of file + + static object GenerateRandomData() + { + List fundList = + [ + new() + { + Id = 1, + Name = "E Fund Money A", + Identity = new Identity(1, "FUND_000001"), + SetupDate = new DateOnly(2019, 5, 20), + NetValues = NetValue.GenerateRandomValues(1, new DateOnly(2025, 1, 1)) + }, + + new() + { + Id = 2, + Name = "Southern Growth Mixed", + Identity = new Identity(2, "FUND_000002"), + SetupDate = new DateOnly(2020, 3, 10), + NetValues = NetValue.GenerateRandomValues(2, new DateOnly(2025, 1, 1)) + }, + + new() + { + Id = 3, + Name = "China Merchants Bond Fund", + Identity = new Identity(3, "FUND_000003"), + SetupDate = new DateOnly(2021, 7, 1), + NetValues = NetValue.GenerateRandomValues(3, new DateOnly(2025, 1, 1)) + }, + + new() + { + Id = 4, + Name = "ChinaAMC CSI 300 ETF", + Identity = new Identity(4, "FUND_000004"), + SetupDate = new DateOnly(2018, 11, 5), + NetValues = NetValue.GenerateRandomValues(4, new DateOnly(2025, 1, 1)) + }, + + new() + { + Id = 5, + Name = "ICBC Credit Suisse New Energy", + Identity = new Identity(5, "FUND_000005"), + SetupDate = new DateOnly(2022, 1, 25), + NetValues = NetValue.GenerateRandomValues(5, new DateOnly(2025, 1, 1)) + } + ]; + + return new + { + Funds = fundList.Select(x => new + { + x.Id, + x.Name, + x.Identity, + x.SetupDate, + x.NetValues, + Latest = new { Date = new DateOnly(2026,1,1), NetValue = 1 }, + SheetName = x.Name + }) + }; + } + + [Fact] + public async Task TestParametrizedSheet() + { + var value = GenerateRandomData(); + + var templatePath = PathHelper.GetFile("xlsx/TestParametrizedSheet.xlsx"); + using var path = AutoDeletingPath.Create(); + await _excelTemplater.FillTemplateAsync(path.ToString(), templatePath, value, true); + + using var package = new ExcelPackage(path.ToString()); + var sheets = package.Workbook.Worksheets; + + Assert.Equal(7, sheets.Count); + + Assert.Equal("Sheet1", sheets[0].Name); + Assert.Equal("1", sheets[0].Cells["A1"].Text); + Assert.Equal("5", sheets[0].Cells["A5"].Text); + Assert.Equal("E Fund Money A", sheets[0].Cells["B1"].Text); + Assert.Equal("ICBC Credit Suisse New Energy", sheets[0].Cells["B5"].Text); + Assert.Equal("FUND_000001", sheets[0].Cells["C1"].Text); + Assert.Equal("FUND_000005", sheets[0].Cells["C5"].Text); + Assert.Equal("2019", sheets[0].Cells["E1"].Text); + Assert.Equal("2022", sheets[0].Cells["E5"].Text); + + Assert.Equal("Southern Growth Mixed", sheets[2].Name); + Assert.Equal("2", sheets[2].Cells["A1"].Text); + Assert.Equal("Southern Growth Mixed", sheets[2].Cells["B1"].Text); + Assert.Equal(new DateOnly(2020, 3, 10).ToString(CultureInfo.CurrentCulture), sheets[2].Cells["C1"].Text); + Assert.Equal(new DateOnly(2025, 1, 1).ToString(CultureInfo.CurrentCulture), sheets[2].Cells["A3"].Text); + + Assert.Equal("Sheet3", sheets[^1].Name); + Assert.Equal("E Fund Money A", sheets[^1].Cells["A1"].Text); + Assert.Equal("ICBC Credit Suisse New Energy", sheets[^1].Cells["A5"].Text); + Assert.Equal(new DateOnly(2026, 1, 1).ToString(CultureInfo.CurrentCulture), sheets[^1].Cells["B1"].Text); + Assert.Equal(new DateOnly(2026, 1, 1).ToString(CultureInfo.CurrentCulture), sheets[^1].Cells["B5"].Text); + Assert.Equal("1", sheets[^1].Cells["C1"].Text); + Assert.Equal("1", sheets[^1].Cells["C5"].Text); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/Models.cs b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/Models.cs new file mode 100644 index 00000000..d6329f1f --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/Models.cs @@ -0,0 +1,56 @@ +namespace MiniExcelLib.OpenXml.Tests.SaveByTemplate; + +internal class TestIEnumerableTypePoco +{ + public string @string { get; set; } + public int? @int { get; set; } + public decimal? @decimal { get; set; } + public double? @double { get; set; } + public DateTime? datetime { get; set; } + public bool? @bool { get; set; } + public Guid? Guid { get; set; } +} + +internal class Employee +{ + public string name { get; set; } + public string department { get; set; } +} + +internal record struct Identity(int Type, string Id); + +internal record NetValue(DateOnly Date, decimal Value) +{ + internal static List GenerateRandomValues(int fundType, DateOnly startDate) + { + var netValues = new List(); + var random = Random.Shared; + + for (int i = 0; i < 30; i++) + { + var value = fundType switch + { + 1 => Math.Round(1.0000m + (decimal)random.NextDouble() * 0.0010m, 4), + 2 => Math.Round(1.2m + (decimal)random.NextDouble() * 0.8m, 4), + 3 => Math.Round(1.05m + (decimal)random.NextDouble() * 0.25m, 4), + 4 => Math.Round(1.1m + (decimal)random.NextDouble() * 0.7m, 4), + 5 => Math.Round(1.5m + (decimal)random.NextDouble() * 1.0m, 4), + _ => 1.0000m + }; + + netValues.Add(new NetValue(startDate.AddDays(i), value)); + } + + return netValues; + } +} + +internal class Fund +{ + public int Id { get; set; } + public string? Name { get; set; } + public Identity Identity { get; set; } + public DateOnly SetupDate { get; set; } + + public List NetValues { get; set; } = []; +} diff --git a/tests/data/xlsx/TestParametrizedSheet.xlsx b/tests/data/xlsx/TestParametrizedSheet.xlsx new file mode 100644 index 00000000..bbb01c41 Binary files /dev/null and b/tests/data/xlsx/TestParametrizedSheet.xlsx differ