Skip to content
Merged
4 changes: 0 additions & 4 deletions src/MiniExcel.OpenXml/MiniExcel.OpenXml.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,4 @@
<InternalsVisibleTo Include="MiniExcel.OpenXml.FluentMapping, PublicKey=002400000480000094000000060200000024000052534131000400000100010091c2c6c10d20b6c884dbc48892f91cc773d33c3a1f43ba3352700031d2d5f6a2b37cccd60469733a4597bdd94b54ee63f514dc487b21be797b1b63063941630b46ad090baabaf0650d05b9c590ea497644f0c296bb223e17dc785f0fbb255ef780905aabf4cf14ee5bca087cbd41d2231169a620529626035215604261b533c9"/>
</ItemGroup>

<ItemGroup>
<Folder Include="FluentMapping\" />
</ItemGroup>

</Project>
35 changes: 22 additions & 13 deletions src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -937,7 +937,16 @@ private void ProcessFormulas(StringBuilder rowXml, int rowIndex)
str.AddBeforeSelf(fNode);
str.Remove();

var celRef = CellReferenceConverter.GetCellFromCoordinates(index, rowIndex);
// the cell no longer holds an inline string; keeping t="inlineStr" without the <is> tag is invalid
cell.Attribute("t")?.Remove();

// take the column from the cell's own reference — the running index is wrong for
// sparse rows (cells without content are not emitted, so position != column)
var rAttr = cell.Attribute("r")?.Value;
var celRef = string.IsNullOrEmpty(rAttr)
? CellReferenceConverter.GetCellFromCoordinates(index, rowIndex)
: rAttr;

_calcChainCellRefs.Add(celRef);
}
}
Expand All @@ -964,11 +973,11 @@ private void ProcessFormulas(StringBuilder rowXml, int rowIndex)
private static StringBuilder CleanXml(StringBuilder xml, string? prefix = null)
{
var sb = xml
.Replace("xmlns:x14ac=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac\"", "")
.Replace("xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"", "");
.Replace($"xmlns:x14ac={Schemas.SpreadsheetmlXmlX14Ac}", "")
.Replace($"xmlns={Schemas.SpreadsheetmlXmlMain}", "");

return !string.IsNullOrEmpty(prefix)
? sb.Replace($"xmlns:{prefix}=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"", "")
? sb.Replace($"xmlns:{prefix}={Schemas.SpreadsheetmlXmlMain}", "")
: sb;
}

Expand All @@ -982,7 +991,7 @@ private static void InjectSharedStrings(IDictionary<int, string> sharedStrings,
var t = cell.Attribute("t");
var v = cell.Element(SpreadsheetNs + "v");

if (v?.Value is null || t?.Value != "s")
if (v?.Value is null || t?.Value != ExcelDataTypes.SharedString)
continue;

//needs to check if sharedstring exists or not
Expand All @@ -995,21 +1004,21 @@ private static void InjectSharedStrings(IDictionary<int, string> sharedStrings,
var tNode = new XElement(SpreadsheetNs + "t", shared);
var isNode = new XElement(SpreadsheetNs + "is", tNode);
cell.Add(isNode);
cell.SetAttributeValue("t", "inlineStr");
cell.SetAttributeValue("t", ExcelDataTypes.InlineString);
}
}
}

private static void SetCellType(XElement cell, string type)
{
// Force inlineStr for strings
if (type == "str")
type = "inlineStr";
if (type == ExcelDataTypes.CalculatedString)
type = ExcelDataTypes.InlineString;

if (type == "inlineStr")
if (type == ExcelDataTypes.InlineString)
{
// Ensure <is><t>...</t></is>
cell.SetAttributeValue("t", "inlineStr");
cell.SetAttributeValue("t", ExcelDataTypes.InlineString);

if (cell.Element(SpreadsheetNs + "v") is { } v)
{
Expand All @@ -1020,7 +1029,7 @@ private static void SetCellType(XElement cell, string type)
var isNode = new XElement(SpreadsheetNs + "is", tNode);

cell.Add(isNode);
cell.SetAttributeValue("t", "inlineStr");
cell.SetAttributeValue("t", ExcelDataTypes.InlineString);
}
else if (cell.Element(SpreadsheetNs + "is") is null)
{
Expand All @@ -1036,8 +1045,8 @@ private static void SetCellType(XElement cell, string type)
// Ensure <v>...</v>
// For numbers/booleans, we remove 't' attribute to let it be default (number)
// or we could set it to 'n' explicitly, but removing is safer for general number types
if (type == "b")
cell.SetAttributeValue("t", "b");
if (type == ExcelDataTypes.Boolean)
cell.SetAttributeValue("t", ExcelDataTypes.Boolean);
else
cell.Attribute("t")?.Remove();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ static void TraverseAndFlatten(
/// Adds worksheets to the workbook and register them int workbook.xml and workbook.xml.rels
/// </summary>
[CreateSyncVersion]
private static async Task BatchAddSheetsToWorkbookAsync(ZipArchive outputZip, ZipArchive templateArchive, List<(int Index, string Name)> sheetInfos, CancellationToken cancellationToken)
private static async Task BatchAddSheetsToWorkbookAsync(ZipArchive outputZip, ZipArchive templateArchive, List<(int Index, string Name)> sheetInfos, bool removeCalcChainFromRels, CancellationToken cancellationToken)
{
// Load the workbook and its relationships from the template
var relDoc = await LoadXmlAsync(templateArchive, ExcelFileNames.WorkbookRels, cancellationToken).ConfigureAwait(false);
Expand All @@ -188,25 +188,34 @@ private static async Task BatchAddSheetsToWorkbookAsync(ZipArchive outputZip, Zi
}

// 2. Clean up all relationship records pointing to worksheets in workbook.xml.rels
var relsRoot = relDoc.Root;
if (relsRoot != null)
if (relDoc.Root is { } relsRoot)
{
// Only delete relationships of Type 'worksheet', preserving core relationships like sharedStrings/styles/theme
// Remove the calcChain relationship if the contents have been invalidated upstream
if (removeCalcChainFromRels)
{
var calcChainRecord = relsRoot.Elements().FirstOrDefault(x =>
x.Attribute("Target")?.Value
.EndsWith("calcChain.xml", StringComparison.OrdinalIgnoreCase) is true
);
calcChainRecord?.Remove();
}

// 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 relationship records for each generated sheet
foreach (var sheet in sheetInfos)
{
relsRoot.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
Expand Down Expand Up @@ -245,10 +254,11 @@ private async Task<Dictionary<string, string>> GetSheetNameMapAsync(ZipArchive a
if (rel.Attribute("Id")?.Value is { } rid)
{
var target = rel.Attribute("Target")?.Value;
if (string.IsNullOrEmpty(rid) || string.IsNullOrEmpty(target)) continue;
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("\\", "/");
var fullSheetPath = Path.Combine("xl", target).Replace("\\", "/").TrimStart('/');
ridToSheetPath[rid] = fullSheetPath;
}
}
Expand Down
53 changes: 25 additions & 28 deletions src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public async Task SaveAsByTemplateAsync(Stream templateStream, object value, Can
var entryName = entry.FullName.TrimStart('/');
if (entryName.StartsWith(ExcelFileNames.WorksheetBase, StringComparison.OrdinalIgnoreCase) ||
entryName.Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase) ||
entryName.Equals(ExcelFileNames.ContentTypes, StringComparison.OrdinalIgnoreCase) ||
entryName.Equals(ExcelFileNames.Workbook, StringComparison.OrdinalIgnoreCase) ||
entryName.Equals(ExcelFileNames.WorkbookRels, StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -124,7 +125,7 @@ await originalEntryStream.CopyToAsync(newEntryStream

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
// XRowInfos must be cleared for every sheet or it'll cause duplicates: https://user-images.githubusercontent.com/12729184/115003101-0fcab700-9ed8-11eb-9151-ca4d7b86d59e.png
_xRowInfos.Clear();
_xMergeCellInfos.Clear();
_newXMergeCellInfos.Clear();
Expand Down Expand Up @@ -155,44 +156,40 @@ await originalEntryStream.CopyToAsync(newEntryStream
}
}

// 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
// The template's own calcChain cannot be reused: row insertion shifts formula cells and its
// entries would point at the old addresses. It is regenerated from the rendered formulas —
// and when none were rendered, dropped entirely, because a calcChain with no <c> entries is
// schema-invalid and Excel rejects the whole package either way.
// Excel rebuilds the chain on open, so dropping it is always safe.
var calcChain = outputFileArchive.EntryCollection.FirstOrDefault(e
=> e.FullName.TrimStart('/').Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase));

if (calcChain is not null)
var contentTypesDoc = await LoadXmlAsync(originalArchive, ExcelFileNames.ContentTypes, cancellationToken).ConfigureAwait(false);
var isValidCalcChain = calcChain is not null && _calcChainContent.Length > 0;

if (isValidCalcChain)
{
var calcChainEntry = outputFileArchive.ZipFile.CreateEntry(calcChain.FullName);
var calcChainEntry = outputFileArchive.ZipFile.CreateEntry(calcChain!.FullName);
var calcChainStream = await calcChainEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var disposableChainEntryStream = calcChainStream.ConfigureAwait(false);

await CalcChainHelper.GenerateCalcChainSheetAsync(calcChainStream, _calcChainContent.ToString(), cancellationToken).ConfigureAwait(false);
}
else
{
foreach (var entry in originalArchive.Entries)
{
if (entry.FullName.TrimStart('/').Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase))
{
var newEntry = outputFileArchive.ZipFile.CreateEntry(entry.FullName);

// Copy the content of the original entry to the new entry
var originalEntryStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var disposableEntryStream = originalEntryStream.ConfigureAwait(false);

var newEntryStream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var disposableNewEntryStream = newEntryStream.ConfigureAwait(false);

await originalEntryStream.CopyToAsync(newEntryStream
#if NET
, cancellationToken
#endif
).ConfigureAwait(false);
}
}
var elements = contentTypesDoc.Root?.Elements();
var calcChainRecord = elements?.FirstOrDefault(x =>
x.Attribute("PartName")?.Value.TrimStart('/')
.Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase) is true
);
calcChainRecord?.Remove();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// saving the (possibly edited) [Content_Types].xml entry
await SaveXmlToZipAsync(outputFileArchive.ZipFile, ExcelFileNames.ContentTypes, contentTypesDoc, cancellationToken).ConfigureAwait(false);

// editing the workbook and its rels to reflect the new worksheets' metadata
await BatchAddSheetsToWorkbookAsync(outputFileArchive.ZipFile, originalArchive, allSheetInfos, !isValidCalcChain, cancellationToken).ConfigureAwait(false);

#if NET10_0_OR_GREATER
await outputFileArchive.ZipFile.DisposeAsync().ConfigureAwait(false);
Expand Down
107 changes: 107 additions & 0 deletions tests/MiniExcel.OpenXml.Tests/Templates/CalcChainAsyncTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Xml.Linq;
using ClosedXML.Excel;
using MiniExcelLib.OpenXml.Constants;
using MiniExcelLib.Tests.Common.Utils;

namespace MiniExcelLib.OpenXml.Tests.Templates;

public class CalcChainAsyncTests
{
private readonly OpenXmlTemplater _templater = MiniExcel.Templaters.GetOpenXmlTemplater();

[Fact]
public async Task TemplateWithStaticFormula_DoesNotWriteStaleOrEmptyCalcChain()
{
// A template with a static Excel formula (below an IEnumerable row) carries a calcChain
// pointing at the formula's pre-render address. After rows are inserted the address is
// stale — the rendered package must not contain a stale or empty calcChain.
using var template = AutoDeletingPath.Create();
using (var wb = new XLWorkbook())
{
var ws = wb.AddWorksheet("Sheet1");
ws.Cell("A1").Value = "{{title}}";
ws.Cell("A3").Value = "{{items.Name}}";
ws.Cell("B3").Value = "{{items.Qty}}";
ws.Cell("B5").FormulaA1 = "SUM(B3:B4)";
wb.SaveAs(template.FilePath);
}

using var path = AutoDeletingPath.Create();
Dictionary<string, object?> data = new()
{
["title"] = "FooCompany",
["items"] = new[]
{
new { Name = "A", Qty = 1 },
new { Name = "B", Qty = 2 },
}
};
await _templater.FillTemplateAsync(path.ToString(), template.FilePath, data);

using var zip = ZipFile.OpenRead(path.ToString());
var calcChain = zip.GetEntry("xl/calcChain.xml");
if (calcChain != null)
{
using var reader = new StreamReader(calcChain.Open());
var content = await reader.ReadToEndAsync();
Assert.Contains("<c ", content); // an empty calcChain is schema-invalid
Assert.DoesNotContain(@"r=""B5""", content); // the pre-render address is stale after the row shift
}
}

[Fact]
public async Task DollarFormulaInSparseRow_WritesValidFormulaCellAndCorrectCalcChainRef()
{
// The '$=' formula sits in column D of a row whose only other cell is in column A, so the
// formula cell's child-list position (1) differs from its column (D) — the calcChain ref
// must come from the cell's own address. The rendered cell must be a real formula element
// in the spreadsheetml namespace, not an inline string.
using var template = AutoDeletingPath.Create();
using (var wb = new XLWorkbook())
{
var ws = wb.AddWorksheet("Sheet1");
// the static formula makes the authoring library emit a calcChain part, so the
// regeneration path runs; without one the template carries no chain to regenerate
ws.Cell("F1").FormulaA1 = "1+1";
ws.Cell("A5").Value = "{{items.Name}}";
ws.Cell("B5").Value = "{{items.Qty}}";
ws.Cell("A7").Value = "Total";
ws.Cell("D7").Value = "$=SUM(B{{$enumrowstart}}:B{{$enumrowend}})";
wb.SaveAs(template.FilePath);
}

using var path = AutoDeletingPath.Create();
Dictionary<string, object?> data = new()
{
["title"] = "FooCompany",
["items"] = new[]
{
new { Name = "A", Qty = 1 },
new { Name = "B", Qty = 2 },
}
};
await _templater.FillTemplateAsync(path.ToString(), template.FilePath, data);

using var zip = ZipFile.OpenRead(path.ToString());

// the formula cell: <c r="D8"> (two items shift row 7 to 8) with a namespaced <f> child and no inlineStr type
XDocument doc;
await using (var sheet = zip.GetEntry("xl/worksheets/sheet1.xml")!.Open())
{
doc = await XDocument.LoadAsync(sheet, System.Xml.Linq.LoadOptions.None, CancellationToken.None);
}

var ns = (XNamespace)Schemas.SpreadsheetmlXmlMain;
var formulaCell = doc.Descendants(ns + "c").FirstOrDefault(x => x.Attribute("r")?.Value == "D8");
Assert.NotNull(formulaCell);
Assert.Equal("SUM(B5:B6)", formulaCell.Element(ns + "f")?.Value);
Assert.NotEqual("inlineStr", formulaCell.Attribute("t")?.Value);

// the regenerated calcChain points at the formula's real address, not the one derived
// from the cell's position in the row (which would be column B here)
using var chainReader = new StreamReader(zip.GetEntry("xl/calcChain.xml")!.Open());
var chain = await chainReader.ReadToEndAsync();
Assert.Contains(@"r=""D8""", chain);
Assert.DoesNotContain(@"r=""B8""", chain);
}
}
Loading
Loading