From 41788ec38e17a9ea191982e8c273b6f4bd960c7b Mon Sep 17 00:00:00 2001 From: k1k Date: Fri, 15 May 2026 09:06:13 +0800 Subject: [PATCH 1/2] extend {{}} support {{Funds.Id}} {{Funds.Name}} {{Funds.Identity.Id}} {{Funds.Identity.Type}} {{Funds.SetupDate.Year}} --- .../Templates/OpenXmlValueExtractorHook.cs | 169 ++++++++++++++++++ tests/data/xlsx/TestObjectExt.xlsx | Bin 0 -> 9179 bytes 2 files changed, 169 insertions(+) create mode 100644 src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs create mode 100644 tests/data/xlsx/TestObjectExt.xlsx diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs new file mode 100644 index 00000000..84028180 --- /dev/null +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs @@ -0,0 +1,169 @@ +using System.ComponentModel; + +namespace MiniExcelLib.OpenXml.Templates; + + + +public static class OpenXmlValueExtractorHook +{ + +#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; + } + + private static string ConvertToDateTimeString(PropertyInfo? propInfo, object? cellValue) + { + if (propInfo == null || cellValue is not DateTime dt) + return XmlHelper.EncodeXml(cellValue?.ToString() ?? ""); + + return XmlHelper.EncodeXml(dt.ToString("yyyy-MM-dd HH:mm:ss")); + } + #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 0000000000000000000000000000000000000000..55daab4296a264cf11c8d5b2aeb9d423037faff8 GIT binary patch literal 9179 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 YH^(h6{p9DviVl8X1P(0MLiC{bKj_NgsQ>@~ literal 0 HcmV?d00001 From b3b8c755a1c41ad1c51448656d2bae1149a6ec20 Mon Sep 17 00:00:00 2001 From: k1k Date: Fri, 15 May 2026 13:23:14 +0800 Subject: [PATCH 2/2] extend gen sheets --- .../Templates/OpenXmlTemplate.Impl.cs | 2 + .../Templates/OpenXmlTemplate.cs | 41 +++- .../Templates/OpenXmlValueExtractorHook.cs | 230 +++++++++++++++++- .../SaveByTemplate/MiniExcelTemplateTests.cs | 181 ++++++++++++-- tests/data/xlsx/TestObjectExt.xlsx | Bin 9179 -> 9967 bytes 5 files changed, 414 insertions(+), 40 deletions(-) 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 index 84028180..a1af41a3 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlValueExtractorHook.cs @@ -1,10 +1,11 @@ using System.ComponentModel; +using System.Xml.Linq; namespace MiniExcelLib.OpenXml.Templates; -public static class OpenXmlValueExtractorHook +internal partial class OpenXmlTemplate { #if !NET8_0_OR_GREATER @@ -158,12 +159,229 @@ private static bool IsSimpleType(Type type) || Nullable.GetUnderlyingType(type) != null; } - private static string ConvertToDateTimeString(PropertyInfo? propInfo, object? cellValue) + #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) { - if (propInfo == null || cellValue is not DateTime dt) - return XmlHelper.EncodeXml(cellValue?.ToString() ?? ""); + 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++}"; + } - return XmlHelper.EncodeXml(dt.ToString("yyyy-MM-dd HH:mm:ss")); + // 🔥 关键:只收集,不调用配置方法 + 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; } - #endregion + + + /// + /// 批量添加工作表到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 index 55daab4296a264cf11c8d5b2aeb9d423037faff8..7de0d3420b48fcda0c6a4566cd179bb76aa3d32b 100644 GIT binary patch delta 3815 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 delta 3109 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