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..b6570b88 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; + // 全局收集所有工作表信息(单表/多表都在这里汇总) + 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)); + } + + + // 【一次性写入所有配置】(修复覆盖BUG,表名生效) + 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..a1af41a3 --- /dev/null +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs @@ -0,0 +1,387 @@ +using System.ComponentModel; +using System.Xml.Linq; + +namespace MiniExcelLib.OpenXml.Templates; + + + +internal partial class OpenXmlTemplate +{ + +#if !NET8_0_OR_GREATER + public class ReferenceComparer : IEqualityComparer + { + public new bool Equals(object x, object y) => ReferenceEquals(x, y); + + public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); + } +#endif + + /// 默认最大递归深度(可根据实际业务调整,10~15 通常足够) + private const int DefaultMaxDepth = 4; + + /// + /// 递归展开成 key.subkey 并完整格式化(防循环引用 / 防深度溢出) + /// + /// 结果字典 + /// 当前键前缀 + /// 当前值 + /// 当前属性信息(用于格式化特性读取) + /// 最大递归深度,超出后将安全降级为 ToString() + public static void AddFlattenedAndFormattedValues( + Dictionary replacements, + string key, + object? value, + PropertyInfo? propInfo = null, + int maxDepth = DefaultMaxDepth) + { + // 使用引用相等比较器,防止业务类型重写 Equals/GetHashCode 导致误判 +#if NET8_0_OR_GREATER + var visited = new HashSet(ReferenceEqualityComparer.Instance); +#else + var visited = new HashSet(new ReferenceComparer()); +#endif + Core(replacements, key, value, propInfo, maxDepth, 0, visited); + } + + private static void Core( + Dictionary replacements, + string key, + object? value, + PropertyInfo? propInfo, + int maxDepth, + int currentDepth, + HashSet visited) + { + if (value == null || value.GetType() is not Type type) + { + replacements[key] = string.Empty; + return; + } + + // 1. 基础类型/枚举:直接格式化,不消耗深度,不进入引用追踪 + if (IsSimpleType(type) || type.IsEnum) + { + replacements[key] = GetFormattedValue(propInfo, value, type); + return; + } + + // 2. 深度控制:超出限制时安全降级,避免 OOM/StackOverflow + if (currentDepth >= maxDepth) + { + replacements[key] = GetFormattedValue(propInfo, value, type); + return; + } + + // 3. 循环引用检测(仅针对引用类型,值类型不可能形成引用环) + if (!type.IsValueType && !visited.Add(value)) + { + replacements[key] = "[CircularReference]"; + return; + } + + try + { + // 4. 字典处理 + if (value is Dictionary dict) + { + foreach (var kv in dict) + { + var subKey = string.Concat(key, ".", kv.Key); + Core(replacements, subKey, kv.Value, propInfo, maxDepth, currentDepth + 1, visited); + } + return; + } + + // 5. 对象属性递归 + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0); + + foreach (var prop in properties) + { + var subKey = string.Concat(key, ".", prop.Name); + var subValue = prop.GetValue(value); + Core(replacements, subKey, subValue, prop, maxDepth, currentDepth + 1, visited); + } + } + finally + { + // 🔑 关键回溯:移除当前节点,允许同一对象在不同分支中被正常访问(DAG共享引用) + // 仅拦截真正的“环”,不误杀合法的对象复用 + if (!type.IsValueType) + visited.Remove(value); + } + } + + #region 你的完整格式化逻辑(独立维护,未改动核心行为) + 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) + { + 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); + } + } + + var tempReplacement = cellValueStr ?? ""; + return tempReplacement.StartsWith("$=") || tempReplacement.StartsWith("=") + ? $"'{tempReplacement}" + : tempReplacement; + } + + 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 + + + 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) + { + var m = Regex.Match(realSheetName, @"\$([^$]+)\$"); + if (m.Success && inputValues.TryGetValue(m.Groups[1].Value, out var subObj) && subObj is IEnumerable sunIter) + { + // 基础表名(从模板占位符提取) + var baseSheetName = m.Groups[1].Value; + var subIndex = 1; + + // 1. 【先批量创建所有工作表文件】(流自动关闭,无冲突) + foreach (var subRoot in sunIter) + { + _xRowInfos.Clear(); + _xMergeCellInfos.Clear(); + _newXMergeCellInfos.Clear(); + _calcChainCellRefs.Clear(); + + var subValues = _inputValueExtractor.ToValueDictionary(subRoot); + + sheetIdx++; + var newSheetPath = $"xl/worksheets/sheet{sheetIdx}.xml"; + + // 处理表名 + string finalSheetName; + if (subValues.TryGetValue("SheetName", out var customSheetName) && customSheetName != null) + { + finalSheetName = customSheetName.ToString()?.Trim() ?? $"{baseSheetName}{subIndex++}"; + } + else + { + finalSheetName = $"{baseSheetName}{sheetIdx++}"; + } + + // 🔥 关键:只收集,不调用配置方法 + allSheetInfos.Add((sheetIdx, finalSheetName)); + + + // 创建工作表(独立作用域,流自动关闭) + var newSheetEntry = outputFileArchive.ZipFile.CreateEntry(newSheetPath); +#if NET8_0_OR_GREATER + await using var newSheetStream = await newSheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#else + using var newSheetStream = newSheetEntry.Open(); +#endif + await GenerateSheetByCreateModeAsync(templateSheet, newSheetStream, subValues, templateSharedStrings, cancellationToken: cancellationToken).ConfigureAwait(false); + + + _calcChainContent.Append(CalcChainHelper.GetCalcChainContent(_calcChainCellRefs, sheetIdx)); + } + + return true; + } + + return false; + } + + + /// + /// 批量添加工作表到Excel配置(一次性写入,无覆盖,表名生效) + /// + [CreateSyncVersion] + public static async Task BatchAddSheetsToExcelConfigAsync(ZipArchive outputZip, ZipArchive templateArchive, List<(int Index, string Name)> sheetInfos, CancellationToken cancellationToken) + { + // ====================================== + // 阶段1:纯内存读取并修改配置(读完立即关闭所有流) + // ====================================== + XDocument relDoc = await LoadTemplateXmlAsync(templateArchive, "xl/_rels/workbook.xml.rels", cancellationToken).ConfigureAwait(false); + XDocument wbDoc = await LoadTemplateXmlAsync(templateArchive, "xl/workbook.xml", cancellationToken).ConfigureAwait(false); + + // 命名空间 + 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. 🗑️ 清空 workbook.xml 中的所有 ,重建干净的 容器 + var sheetsPart = wbDoc.Root?.Element(ssNs + "sheets"); + if (sheetsPart != null) + { + // 直接清空子节点,保留容器本身及默认命名空间(避免命名空间丢失导致 Excel 报错) + sheetsPart.Elements().Remove(); + } + else + { + // 若原模板无 sheets 节点,则新建一个追加到根节点 + wbDoc.Root?.Add(new XElement(ssNs + "sheets")); + } + + // 2. 🔗 清理 workbook.xml.rels 中所有指向 worksheet 的关系记录 + const string worksheetRelType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"; + + var relsRoot = relDoc.Root; + if (relsRoot != null) + { + // 仅删除 Type 为 worksheet 的关系,保留 sharedStrings/styles/theme 等核心关系 + var worksheetRels = relsRoot.Elements(relNs + "Relationship") + .Where(r => r.Attribute("Type")?.Value == worksheetRelType) + .ToList(); + + foreach (var rel in worksheetRels) rel.Remove(); + } + + + // 批量添加关系 + 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"))); + } + + // 批量添加工作表 + 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}"))); + } + } + + // ====================================== + // 阶段2:所有流已关闭 → 安全创建条目并写入 + // ====================================== + await SaveXmlToZipAsync(outputZip, "xl/_rels/workbook.xml.rels", relDoc, cancellationToken).ConfigureAwait(false); + await SaveXmlToZipAsync(outputZip, "xl/workbook.xml", wbDoc, cancellationToken).ConfigureAwait(false); + } + + /// + /// 读取模板XML(流自动关闭,返回内存XDocument) + /// + [CreateSyncVersion] + private static async Task LoadTemplateXmlAsync(ZipArchive templateArchive, string path, CancellationToken cancellationToken) + { + var entry = templateArchive.GetEntry(path)!; +#if NET8_0_OR_GREATER + await 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 + } + + /// + /// 写入XML到压缩包(流自动关闭,无残留) + /// + [CreateSyncVersion] + private static async Task SaveXmlToZipAsync(ZipArchive outputZip, string path, XDocument doc, CancellationToken cancellationToken) + { + // 🔥 此时无任何打开流,安全创建条目 + + + var newEntry = outputZip.CreateEntry(path); +#if NET8_0_OR_GREATER + await 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 + } + + + + + /// + /// 精准解析:获取【真实工作表名】+ 对应sheet xml路径 + /// + public Dictionary GetRealSheetNameMap(ZipArchive archive) + { + var nameToPath = new Dictionary(); + var ridToSheetPath = new Dictionary(); + + // 1. 读取 workbook.xml.rels 拿到 rId → sheet文件路径 + var relsEntry = archive.Entries.FirstOrDefault(e => e.FullName == "xl/_rels/workbook.xml.rels"); + if (relsEntry == null) return nameToPath; + + using var relStream = relsEntry.Open(); + var relDoc = XDocument.Load(relStream); + XNamespace relNs = "http://schemas.openxmlformats.org/package/2006/relationships"; + + 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; + // 拼接完整路径 + var fullSheetPath = Path.Combine("xl", target).Replace("\\", "/"); + ridToSheetPath[rid] = fullSheetPath; + } + + // 2. 读取 workbook.xml 拿到 真实表名 + rId + var wbEntry = archive.Entries.FirstOrDefault(e => e.FullName == "xl/workbook.xml"); + if (wbEntry == null) return nameToPath; + + 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"; + + 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 (ridToSheetPath.TryGetValue(rid, out var sheetPath)) + { + nameToPath[sheetPath] = realName; // key:xml路径 value:真实表名 + } + } + 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