diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs index 859654d0..07a5987a 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs @@ -701,6 +701,8 @@ private async Task GenerateCellValuesAsync( : tempReplacement; replacements[key] = replacementValue; + AddFlattenedAndFormattedValues(replacements, key, cellValue, propInfo); + rowXml.Replace($"@header{{{{{key}}}}}", replacementValue); if (isHeaderRow && row.Value.Contains(key)) diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs index eb52eaa3..e298afdd 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -44,14 +44,14 @@ 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 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 @@ -66,7 +66,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 @@ -75,14 +75,16 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can #else using var originalArchive = new ZipArchive(templateStream, ZipArchiveMode.Read); #endif + // sheet name map + var sheetPathRealNameMap = GetRealSheetNameMap(originalArchive); // Iterate through each entry in the original archive foreach (var entry in originalArchive.Entries) { var entryName = entry.FullName.TrimStart('/'); - if (entryName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || entryName.Equals("xl/calcChain.xml")) + if (entryName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || entryName.Equals("xl/calcChain.xml") || entryName.Equals("xl/workbook.xml") || entryName.Equals("xl/_rels/workbook.xml.rels")) continue; - + // Create a new entry in the new archive with the same name var newEntry = outputFileArchive.ZipFile.CreateEntry(entry.FullName); @@ -109,6 +111,7 @@ await originalEntryStream.CopyToAsync(newEntryStream var templateSharedStrings = templateReader.SharedStrings; templateStream.Position = 0; + //read all xlsx sheets var templateSheets = templateReader.Archive.ZipFile.Entries .Where(entry => entry.FullName @@ -116,6 +119,9 @@ await originalEntryStream.CopyToAsync(newEntryStream .StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase)); int sheetIdx = 0; + // collect all sheet info for batch add to config, avoid duplicated and missing sheet name when create mode + var allSheetInfos = new List<(int Index, string Name)>(); + 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 @@ -123,9 +129,15 @@ await originalEntryStream.CopyToAsync(newEntryStream _xMergeCellInfos.Clear(); _newXMergeCellInfos.Clear(); _calcChainCellRefs.Clear(); - + var templateFullName = templateSheet.FullName; var inputValues = _inputValueExtractor.ToValueDictionary(value); + sheetPathRealNameMap.TryGetValue(templateFullName, out var realSheetName); + + + if (await HookSheetProcess(outputFileArchive, realSheetName, templateSharedStrings, sheetIdx, allSheetInfos, templateSheet, templateFullName, inputValues, cancellationToken).ConfigureAwait(false)) + break; + var outputZipEntry = outputFileArchive.ZipFile.CreateEntry(templateFullName); #if NET8_0_OR_GREATER @@ -135,14 +147,22 @@ await originalEntryStream.CopyToAsync(newEntryStream using var outputZipSheetEntryStream = outputZipEntry.Open(); #endif 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++; + allSheetInfos.Add((sheetIdx, realSheetName)); _calcChainContent.Append(CalcChainHelper.GetCalcChainContent(_calcChainCellRefs, sheetIdx)); + } + + + // batch add sheet + await BatchAddSheetsToExcelConfigAsync(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.Contains("xl/calcChain.xml")); if (calcChain is not null) @@ -194,4 +214,5 @@ await originalEntryStream.CopyToAsync(newEntryStream outputFileArchive.ZipFile.Dispose(); #endif } -} + + } diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs new file mode 100644 index 00000000..f1d0645b --- /dev/null +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs @@ -0,0 +1,490 @@ +using System.ComponentModel; +using System.Xml.Linq; + +namespace MiniExcelLib.OpenXml.Templates; + +/// +/// To Support +/// +/// public record Identity(int Type, string No); +/// +/// public class Person +/// { +/// public Identity Id { get; set; } +/// public string Name { get; set; } +/// } +/// +/// var obj = new { p = new Person { Id = new Identity(1, "A123"), Name = "张三" }, ps = [..some Person] }; +/// +/// in the template, you can write: +/// case 1 +/// {{p.Id.Type}}}, {{p.Id.No}}}, {{p.Name}} +/// +/// case 2 +/// set sheet name to $ps$ and template +/// {{Id.Type}}}, {{Id.No}}}, {{Name}} +/// then it will generate multiple sheets based on the elements in ps, and each sheet will have the corresponding values of Id.Type, Id.No, Name for that element. +/// +internal partial class OpenXmlTemplate +{ + +#if !NET8_0_OR_GREATER + /// + /// Custom equality comparer that uses reference equality instead of overridden object.Equals. + /// Required for .NET versions prior to 8.0 where ReferenceEqualityComparer is not built-in. + /// + public class ReferenceComparer : IEqualityComparer + { + /// + /// Determines whether the specified objects are the exact same instance in memory. + /// + public new bool Equals(object x, object y) => ReferenceEquals(x, y); + + /// + /// Returns a hash code based on the object's memory reference. + /// + public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); + } +#endif + + /// Default max recursion depth (can be adjusted based on business needs) + private const int DefaultMaxDepth = 4; + + /// + /// 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. + /// + /// The target dictionary to store the flattened key-value results. + /// The current key prefix for the nested property. + /// The current object value to process. + /// The PropertyInfo of the current property, used for reading custom formatting attributes. + /// The maximum allowed recursion depth. Falls back to ToString() when exceeded. + public static void AddFlattenedAndFormattedValues( + Dictionary replacements, + string key, + object? value, + PropertyInfo? propInfo = null, + int maxDepth = DefaultMaxDepth) + { + // Initialize a HashSet to track visited objects and prevent infinite loops from circular references. + // Use reference equality comparer to prevent misjudgments caused by business types overriding Equals/GetHashCode. +#if NET8_0_OR_GREATER + var visited = new HashSet(ReferenceEqualityComparer.Instance); +#else + var visited = new HashSet(new ReferenceComparer()); +#endif + + // Start the recursive core processing with initial depth set to 0. + Core(replacements, key, value, propInfo, maxDepth, 0, visited); + } + + /// + /// The internal recursive method that performs the actual object traversal, flattening, and formatting. + /// + private static void Core( + Dictionary replacements, + string key, + object? value, + PropertyInfo? propInfo, + int maxDepth, + int currentDepth, + HashSet visited) + { + // Handle null values or invalid types by assigning an empty string to the current key and exiting early. + if (value == null || value.GetType() is not Type type) + { + replacements[key] = string.Empty; + return; + } + + // 1. Primitive types / Enums: Format directly, do not consume depth, and do not enter reference tracking. + if (IsSimpleType(type) || type.IsEnum) + { + 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 the object is already in the visited set, it's a circular reference. + 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 kv in dict) + { + // Construct the new sub-key by appending the dictionary key. + var subKey = string.Concat(key, ".", kv.Key); + // Recursively call Core for the dictionary value. + Core(replacements, subKey, kv.Value, propInfo, maxDepth, currentDepth + 1, visited); + } + return; + } + + // 5. Object property recursion: Reflect over public instance properties. + // Filter 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); + // Retrieve the actual value of the property from the current object instance. + var subValue = prop.GetValue(value); + // Recursively call Core for the property value. + Core(replacements, subKey, subValue, prop, maxDepth, currentDepth + 1, visited); + } + } + finally + { + // Remove the current node from the visited set. + // This allows the same object to be accessed normally in different branches (DAG shared references). + // It only intercepts true "loops" (A -> B -> A) and does not kill legitimate object reuse (A -> B, A -> C). + if (!type.IsValueType) + visited.Remove(value); + } + } + + #region Formatting Logic (Maintained independently, core behavior unchanged) + + /// + /// 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) + { + // Variable to hold the intermediate string representation of the cell value. + string? cellValueStr; + + // handle as original write + if (type == typeof(bool)) + { + cellValueStr = (bool)cellValue! ? "1" : "0"; + } + else if (type == typeof(DateTime)) + { + cellValueStr = ConvertToDateTimeString(propInfo, cellValue); + } + else if (type.IsEnum is true) + { + // Get the string name of the enum value. + var stringValue = Enum.GetName(type, cellValue!) ?? ""; + // Retrieve the DescriptionAttribute from the enum field. + var attr = type.GetField(stringValue)?.GetCustomAttribute(); + // Use the description if it exists, otherwise fallback to the enum string name. + 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)) + { + if (decimal.TryParse(cellValueStr, out var decimalValue)) + cellValueStr = decimalValue.ToString(CultureInfo.InvariantCulture); + } + } + var tempReplacement = cellValueStr ?? ""; + + return tempReplacement.StartsWith("$=") || tempReplacement.StartsWith("=") + ? $"'{tempReplacement}" + : tempReplacement; + } + + /// + /// Determines if a given type is a simple/primitive type that should not be recursively traversed. + /// + private static bool IsSimpleType(Type type) + { + return type.IsPrimitive || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(Guid) || Nullable.GetUnderlyingType(type) != null; + } + + #endregion + + /// + /// 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. + /// + /// The output ZIP archive where new sheets will be created. + /// The original name of the sheet from the template. + /// Dictionary of shared strings from the template. + /// The current sheet index counter. + /// List to collect information about all generated sheets for later workbook configuration. + /// The ZIP entry of the template sheet to clone. + /// The full name/path of the template sheet. + /// The dictionary of input values provided for template rendering. + /// Token to monitor for cancellation requests. + /// True if dynamic sheets were generated, otherwise false. + private async Task HookSheetProcess(OpenXmlZip outputFileArchive, string realSheetName, IDictionary templateSharedStrings, int sheetIdx, List<(int Index, string Name)> allSheetInfos, ZipArchiveEntry templateSheet, string templateFullName, IDictionary inputValues, CancellationToken cancellationToken) + { + // Use regex to match the sheet name pattern "$PlaceholderName$" + var m = Regex.Match(realSheetName, @"\$([^$]+)\$"); + + // Check if the pattern matches, the placeholder exists in input values, and the value is an IEnumerable + if (m.Success && inputValues.TryGetValue(m.Groups[1].Value, out var subObj) && subObj is IEnumerable sunIter) + { + // Extract the base sheet name from the template placeholder + var baseSheetName = m.Groups[1].Value; + var subIndex = 1; // Sub-index for naming sheets if custom names are not provided + + // 1. Batch create all worksheet files first (streams are automatically closed, preventing conflicts) + foreach (var subRoot in sunIter) + { + // 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); + + // Increment the global sheet index + sheetIdx++; + // Define the internal path for the new sheet XML file + var newSheetPath = $"xl/worksheets/sheet{sheetIdx}.xml"; + + // Determine the final sheet name + string finalSheetName; + // Check if a custom "SheetName" is provided in the current item's values + if (subValues.TryGetValue("SheetName", out var customSheetName) && customSheetName != null) + { + finalSheetName = customSheetName.ToString()?.Trim() ?? $"{baseSheetName}{subIndex++}"; + } + else + { + // Fallback to base name + index if no custom name is provided + finalSheetName = $"{baseSheetName}{sheetIdx++}"; + } + + // Only collect sheet info, do not call configuration methods yet + allSheetInfos.Add((sheetIdx, finalSheetName)); + + // Create the new worksheet entry in the output ZIP archive + var newSheetEntry = outputFileArchive.ZipFile.CreateEntry(newSheetPath); + + // Open the stream for the new sheet entry (handling .NET 8+ async differences) +#if NET8_0_OR_GREATER + using var newSheetStream = await newSheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#else + using var newSheetStream = newSheetEntry.Open(); +#endif + // 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, sheetIdx)); + } + + // Return true to indicate that dynamic sheets were successfully processed + return true; + } + + // Return false if the sheet name did not match the dynamic generation pattern + return false; + } + + /// + /// Batch adds worksheets to the Excel configuration (writes all at once, no overwriting, ensures sheet names take effect). + /// Modifies workbook.xml and workbook.xml.rels to register the newly created sheets. + /// + /// The output ZIP archive being generated. + /// The original template ZIP archive. + /// List of tuples containing the sheet index and final name. + /// Token to monitor for cancellation requests. + [CreateSyncVersion] + public static async Task BatchAddSheetsToExcelConfigAsync(ZipArchive outputZip, ZipArchive templateArchive, List<(int Index, string Name)> sheetInfos, CancellationToken cancellationToken) + { + // ====================================== + // Phase 1: Pure in-memory reading and modification (all streams are closed immediately after reading) + // ====================================== + + // Load the relationships XML from the template + XDocument relDoc = await LoadTemplateXmlAsync(templateArchive, "xl/_rels/workbook.xml.rels", cancellationToken).ConfigureAwait(false); + // Load the main workbook XML from the template + XDocument wbDoc = await LoadTemplateXmlAsync(templateArchive, "xl/workbook.xml", cancellationToken).ConfigureAwait(false); + + // Define standard OpenXml namespaces + XNamespace relNs = "http://schemas.openxmlformats.org/package/2006/relationships"; + XNamespace ssNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + XNamespace rNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + // 1. Clear all existing elements in workbook.xml to rebuild a clean container + var sheetsPart = wbDoc.Root?.Element(ssNs + "sheets"); + if (sheetsPart != null) + { + // Directly remove child nodes, keeping the container itself and default namespaces + // (to avoid Excel errors caused by missing 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(ssNs + "sheets")); + } + + // 2. Clean up all relationship records pointing to worksheets in workbook.xml.rels + const string worksheetRelType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"; + + var relsRoot = relDoc.Root; + if (relsRoot != null) + { + // Only delete relationships of Type 'worksheet', preserving core relationships like sharedStrings/styles/theme + var worksheetRels = relsRoot.Elements(relNs + "Relationship") + .Where(r => r.Attribute("Type")?.Value == worksheetRelType) + .ToList(); + + // 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(relNs + "Relationship", + new XAttribute("Id", $"rIdSheet{sheet.Index}"), + new XAttribute("Type", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"), + new XAttribute("Target", $"worksheets/sheet{sheet.Index}.xml"))); + } + + // Batch add new sheet definitions to the workbook XML + var sheetsNode = wbDoc.Descendants(ssNs + "sheets").FirstOrDefault(); + if (sheetsNode != null) + { + foreach (var sheet in sheetInfos) + { + sheetsNode.Add(new XElement(ssNs + "sheet", + new XAttribute("name", sheet.Name), + new XAttribute("sheetId", sheet.Index), + new XAttribute(rNs + "id", $"rIdSheet{sheet.Index}"))); + } + } + + // ====================================== + // Phase 2: All streams are closed → Safely create entries and write modified XML back to the ZIP + // ====================================== + + // Save the modified relationships XML + await SaveXmlToZipAsync(outputZip, "xl/_rels/workbook.xml.rels", relDoc, cancellationToken).ConfigureAwait(false); + // Save the modified workbook XML + await SaveXmlToZipAsync(outputZip, "xl/workbook.xml", wbDoc, cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads an XML file from the template archive into memory (stream is automatically closed, returns an in-memory XDocument). + /// + /// The template ZIP archive. + /// The internal path of the XML file to load. + /// Token to monitor for cancellation requests. + /// The loaded XDocument. + [CreateSyncVersion] + private static async Task LoadTemplateXmlAsync(ZipArchive templateArchive, string path, CancellationToken cancellationToken) + { + // Retrieve the ZIP entry for the specified path + var entry = templateArchive.GetEntry(path)!; + + // Open the stream and load the XDocument, handling .NET 8+ async differences +#if NET8_0_OR_GREATER + using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + return await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#else + using var stream = entry.Open(); + return XDocument.Load(stream); +#endif + } + + /// + /// Writes an XDocument to a specified path within the output ZIP archive (stream is automatically closed, no residual locks). + /// + /// The output ZIP archive. + /// The internal path where the XML file should be saved. + /// The XDocument to save. + /// Token to monitor for cancellation requests. + [CreateSyncVersion] + private static async Task SaveXmlToZipAsync(ZipArchive outputZip, string path, XDocument doc, CancellationToken cancellationToken) + { + // At this point, no streams are open, so it is safe to create a new entry + + // Create the new ZIP entry + var newEntry = outputZip.CreateEntry(path); + + // Open the stream and save the XDocument, handling .NET 8+ async differences +#if NET8_0_OR_GREATER + using var stream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await doc.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); +#else + using var stream = newEntry.Open(); + doc.Save(stream); +#endif + } + + /// + /// Accurately parses the template to build a mapping of [Real Sheet Name] to [Corresponding Sheet XML Path]. + /// + /// The ZIP archive to parse. + /// A dictionary where the key is the sheet XML path and the value is the real sheet name. + public Dictionary GetRealSheetNameMap(ZipArchive archive) + { + // Dictionary to hold the final mapping of XML path -> Real Sheet Name + var nameToPath = new Dictionary(); + // Temporary dictionary to hold the mapping of Relationship Id -> Sheet XML Path + var ridToSheetPath = new Dictionary(); + + // 1. Read workbook.xml.rels to get the mapping of rId -> sheet file path + var relsEntry = archive.Entries.FirstOrDefault(e => e.FullName == "xl/_rels/workbook.xml.rels"); + if (relsEntry == null) return nameToPath; // Return empty if relationships file is missing + + using var relStream = relsEntry.Open(); + var relDoc = XDocument.Load(relStream); + XNamespace relNs = "http://schemas.openxmlformats.org/package/2006/relationships"; + + // Iterate through all Relationship elements + foreach (var rel in relDoc.Descendants(relNs + "Relationship")) + { + var rid = rel.Attribute("Id")?.Value; + 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 + var wbEntry = archive.Entries.FirstOrDefault(e => e.FullName == "xl/workbook.xml"); + if (wbEntry == null) return nameToPath; // Return empty if workbook file is missing + + using var wbStream = wbEntry.Open(); + var wbDoc = XDocument.Load(wbStream); + XNamespace ssNs = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + XNamespace rNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + // Iterate through all sheet definitions in the workbook + foreach (var sheetNode in wbDoc.Descendants(ssNs + "sheet")) + { + var realName = sheetNode.Attribute("name")?.Value?.Trim() ?? ""; + var rid = sheetNode.Attribute(rNs + "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)) + { + nameToPath[sheetPath] = realName; // key: xml path, value: real sheet name + } + } + + // Return the completed mapping + return nameToPath; + } +} \ No newline at end of file diff --git a/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs b/tests/MiniExcel.OpenXml.Tests/SaveByTemplate/MiniExcelTemplateTests.cs index 7f96a86d..90e6d468 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", @@ -576,12 +576,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 +618,7 @@ public void TemplateBasicTest() var templatePath = PathHelper.GetFile("xlsx/TestTemplateEasyFill.xlsx"); { using var path = AutoDeletingPath.Create(); - + // 1. By POCO var value = new { @@ -667,7 +667,7 @@ public void TemplateBasicTest() { var path = AutoDeletingPath.Create(); var templateBytes = File.ReadAllBytes(templatePath); - + // 1. By POCO var value = new { @@ -694,7 +694,7 @@ public void TemplateBasicTest() { using var path = AutoDeletingPath.Create(); - + // 2. By Dictionary var value = new Dictionary { @@ -964,4 +964,137 @@ public void TestMergeSameCellsWithLimitTag() Assert.Equal("C3:C6", mergedCells[1]); Assert.Equal("A5:A6", mergedCells[2]); } + + #region Extend + + public record struct Identity(int Type, string Id); + + private 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; } = []; + } + + public record NetValue(DateOnly Date, decimal Value); + private static object GenerateData() + { + // 初始化基金基础数据 + 生成对应净值数据 + List fundList = new List + { + new Fund + { + Id = 1, + Name = "易方达货币A", + Identity = new Identity(1, "FUND_000001"), + SetupDate = new DateOnly(2019, 5, 20), + NetValues = GenerateNetValues(1, new DateOnly(2025, 1, 1)) + }, + new Fund + { + Id = 2, + Name = "南方成长混合", + Identity = new Identity(2, "FUND_000002"), + SetupDate = new DateOnly(2020, 3, 10), + NetValues = GenerateNetValues(2, new DateOnly(2025, 1, 1)) + }, + new Fund + { + Id = 3, + Name = "招商债券基金", + Identity = new Identity(3, "FUND_000003"), + SetupDate = new DateOnly(2021, 7, 1), + NetValues = GenerateNetValues(3, new DateOnly(2025, 1, 1)) + }, + new Fund + { + Id = 4, + Name = "华夏沪深300ETF", + Identity = new Identity(4, "FUND_000004"), + SetupDate = new DateOnly(2018, 11, 5), + NetValues = GenerateNetValues(4, new DateOnly(2025, 1, 1)) + }, + new Fund + { + Id = 5, + Name = "工银瑞信新能源", + Identity = new Identity(5, "FUND_000005"), + SetupDate = new DateOnly(2022, 1, 25), + NetValues = GenerateNetValues(5, new DateOnly(2025, 1, 1)) + } + }; + + // 返回完整数据(包含净值列表) + var value = new + { + Funds = fundList.Select(x => new + { + x.Id, + x.Name, + x.Identity, + x.SetupDate, + x.NetValues, + SheetName = x.Name + }) + }; + return value; + } + + /// + /// 辅助方法:根据基金类型生成模拟净值数据 + /// + /// 基金类型 + /// 开始日期 + /// 30条连续日期的净值列表 + private static List GenerateNetValues(int fundType, DateOnly startDate) + { + var netValues = new List(); + var random = Random.Shared; + + // 生成30条连续的净值数据 + for (int i = 0; i < 30; i++) + { + decimal value = fundType switch + { + // 货币基金:净值稳定在 1.0000 左右 + 1 => Math.Round(1.0000m + (decimal)random.NextDouble() * 0.0010m, 4), + // 混合型基金:净值 1.2 ~ 2.0 + 2 => Math.Round(1.2m + (decimal)random.NextDouble() * 0.8m, 4), + // 债券基金:净值 1.05 ~ 1.30 + 3 => Math.Round(1.05m + (decimal)random.NextDouble() * 0.25m, 4), + // ETF基金:净值 1.1 ~ 1.8 + 4 => Math.Round(1.1m + (decimal)random.NextDouble() * 0.7m, 4), + // 新能源主题基金:净值 1.5 ~ 2.5(波动较大) + 5 => Math.Round(1.5m + (decimal)random.NextDouble() * 1.0m, 4), + _ => 1.0000m + }; + + netValues.Add(new NetValue(startDate.AddDays(i), value)); + } + + return netValues; + } + + + [Fact] + public async Task TestExtend() + { + // 造 5 条测试数据 + var value = GenerateData(); + + var templatePath = PathHelper.GetFile("xlsx/TestObjectExt.xlsx"); + + var path = "object-ext.xlsx"; + File.Delete(path); + + await _excelTemplater.FillTemplateAsync(path, templatePath, value); + + Assert.True(true); + } + + #endregion + } \ No newline at end of file diff --git a/tests/data/xlsx/TestObjectExt.xlsx b/tests/data/xlsx/TestObjectExt.xlsx new file mode 100644 index 00000000..7de0d342 Binary files /dev/null and b/tests/data/xlsx/TestObjectExt.xlsx differ