diff --git a/README-V2.md b/README-V2.md
index e3ec0d16..fc5374d6 100644
--- a/README-V2.md
+++ b/README-V2.md
@@ -1154,6 +1154,50 @@ templater.ApplyTemplate(path, templatePath, value, config)

+#### 12. Dynamically generating sheets
+When you set a template sheet name to `$property$`, and the corresponding property holds an enumerable, filling the template will generate a sheet for each element.
+
+Take the following template and relative code snippet as an example:
+
+
+
+```csharp
+public record Identity(int Type, string Id);
+public record NetValue(DateOnly Date, decimal Value);
+
+public 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; } = [];
+}
+
+var funds = new
+{
+ Funds = fundList.Select(x => new
+ {
+ x.Id,
+ x.Name,
+ x.Identity,
+ x.SetupDate,
+ x.NetValues,
+ SheetName = x.Name
+ })
+};
+
+templater.ApplyTemplate(path, templatePath, funds)
+```
+If the fund entity contains a `SheetName` property, the generated sheet will be named after the value of `SheetName`.
+If no `SheetName` property exists, they will be named sequentially (e.g. fund1, fund2, fund3, etc).
+
+Within each fund sheet, the primary object is the fund itself; you may reference fields directly in the template with placeholders like `{{Id}}` or `{{Name}}`.
+Nested property references such as `{{Fund.Identy.Type}}` are supported.
+
+Result:
+
### Attributes and configuration
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