diff --git a/README-NuGet.md b/README-NuGet.md index 39c03ff8..0dd2546c 100644 --- a/README-NuGet.md +++ b/README-NuGet.md @@ -1,10 +1,5 @@ ## MiniExcel -[![NuGet Version](https://img.shields.io/nuget/v/MiniExcel.svg)](https://www.nuget.org/packages/MiniExcel)  -[![NuGet Downloads](https://img.shields.io/nuget/dt/MiniExcel.svg)](https://www.nuget.org/packages/MiniExcel)  -[![GitHub Stars](https://img.shields.io/github/stars/mini-software/MiniExcel?logo=github)](https://github.com/mini-software/MiniExcel)  -[![Gitee Stars](https://gitee.com/dotnetchina/MiniExcel/badge/star.svg)](https://gitee.com/dotnetchina/MiniExcel)  -[![.NET Version](https://img.shields.io/badge/.NET-%3E%3D%204.5-red.svg)](https://www.nuget.org/packages/MiniExcel)  -[![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mini-software/MiniExcel) +[![NuGet Version](https://img.shields.io/nuget/v/MiniExcel.svg)](https://www.nuget.org/packages/MiniExcel) [![NuGet Downloads](https://img.shields.io/nuget/dt/MiniExcel.svg)](https://www.nuget.org/packages/MiniExcel) [![GitHub Stars](https://img.shields.io/github/stars/mini-software/MiniExcel?logo=github)](https://github.com/mini-software/MiniExcel) [![Gitee Stars](https://gitee.com/dotnetchina/MiniExcel/badge/star.svg)](https://gitee.com/dotnetchina/MiniExcel) [![.NET Version](https://img.shields.io/badge/.NET-%3E%3D%204.5-red.svg)](https://www.nuget.org/packages/MiniExcel) [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mini-software/MiniExcel) This project is part of the [.NET Foundation](https://dotnetfoundation.org/projects/project-detail/miniexcel) and operates under their code of conduct. diff --git a/src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.Impl.cs b/src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.Impl.cs index f075a21f..93e5548d 100644 --- a/src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.Impl.cs +++ b/src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.Impl.cs @@ -1006,12 +1006,23 @@ private void ProcessFormulas(StringBuilder rowXml, int rowIndex) if (!v.InnerText.StartsWith("$=")) continue; - var fNode = c.OwnerDocument.CreateElement("f", Config.SpreadsheetmlXmlns); + // create the f element with the document's prefix: an unprefixed element would rely on a + // default xmlns declaration that CleanXml strips, leaving in no namespace (invalid for Excel) + var fNode = c.OwnerDocument!.CreateElement(c.Prefix, "f", Config.SpreadsheetmlXmlns); fNode.InnerText = v.InnerText.Substring(2); c.InsertBefore(fNode, v); c.RemoveChild(v); - var celRef = ExcelOpenXmlUtils.ConvertXyToCell(ci + 1, rowIndex); + // the cell no longer holds an inline string; keeping t="inlineStr" without is invalid + c.RemoveAttribute("t"); + + // take the column from the cell's own reference — + // the position in the child list is wrong for sparse rows (cells without content are not emitted) + var rAttr = c.GetAttribute("r"); + var celRef = string.IsNullOrEmpty(rAttr) + ? ExcelOpenXmlUtils.ConvertXyToCell(ci + 1, rowIndex) + : rAttr; + _calcChainCellRefs.Add(celRef); } } @@ -1075,7 +1086,8 @@ private static void ReplaceSharedStringsToStr(IDictionary sharedStr c.AppendChild(isNode); c.RemoveAttribute("t"); - c.SetAttribute("t", "inlineStr"); } + c.SetAttribute("t", "inlineStr"); + } } } @@ -1514,4 +1526,4 @@ private static bool EvaluateStatement(object tagValue, string comparisonOperator return false; } -} \ No newline at end of file +} diff --git a/src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.cs b/src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.cs index 646b49a7..fee3a091 100644 --- a/src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.cs +++ b/src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Xml; +using System.Xml.Linq; namespace MiniExcelLibs.OpenXml.SaveByTemplate; @@ -57,6 +58,7 @@ public void SaveAsByTemplate(Stream templateStream, object value) var templateReader = new ExcelOpenXmlSheetReader(templateStream, null); var outputFileArchive = new ExcelOpenXmlZip(_outputFileStream, mode: ZipArchiveMode.Create, true, Encoding.UTF8, isUpdateMode: false); + try { outputFileArchive.entries = templateReader._archive.zipFile.Entries; //TODO:need to remove @@ -70,23 +72,19 @@ public void SaveAsByTemplate(Stream templateStream, object value) { outputFileArchive._entries.Add(entry.FullName.Replace('\\', '/'), entry); } - + templateStream.Position = 0; using (var originalArchive = new ZipArchive(templateStream, ZipArchiveMode.Read)) { - // Create a new zip file for writing - //using (FileStream newZipStream = new FileStream(newZipPath, FileMode.Create)) - //using (ZipArchive newArchive = new ZipArchive(_outputFileStream, ZipArchiveMode.Create)) - // Iterate through each entry in the original archive foreach (ZipArchiveEntry entry in originalArchive.Entries) { - if (entry.FullName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || - entry.FullName.StartsWith("/xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || - entry.FullName.Contains("xl/calcChain.xml") - ) + if (entry.FullName.TrimStart('/').StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || + entry.FullName.Contains("xl/calcChain.xml") || + entry.FullName.Contains("workbook.xml.rels") || + entry.FullName.Contains("[Content_Types].xml")) continue; - + // Create a new entry in the new archive with the same name var newEntry = outputFileArchive.zipFile.CreateEntry(entry.FullName); @@ -104,9 +102,7 @@ public void SaveAsByTemplate(Stream templateStream, object value) //read all xlsx sheets var templateSheets = templateReader._archive.zipFile.Entries - .Where(w => - w.FullName.StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase) || - w.FullName.StartsWith("/xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase)); + .Where(w => w.FullName.TrimStart('/').StartsWith("xl/worksheets/sheet", StringComparison.OrdinalIgnoreCase)); int sheetIdx = 0; foreach (var templateSheet in templateSheets) @@ -128,38 +124,60 @@ public void SaveAsByTemplate(Stream templateStream, object value) // disposing writer disposes streams as well. read and parse calc functions before that sheetIdx++; _calcChainContent.Append(CalcChainHelper.GetCalcChainContent(_calcChainCellRefs, sheetIdx)); + _calcChainCellRefs.Clear(); } } - // create mode we need to not create first then create here + // loading [Content_Types] and workbook.rels for later editing and saving + XDocument wbRelsDoc; + var wbRelsEntry = originalArchive.Entries.First(e => e.FullName.Contains("workbook.xml.rels")); + using (var wbRelsStream = wbRelsEntry.Open()) + { + wbRelsDoc = XDocument.Load(wbRelsStream); + } + + XDocument cTypesDoc; + var cTypesEntry = originalArchive.Entries.First(e => e.FullName.Contains("[Content_Types].xml")); + using (var cTypesStream = cTypesEntry.Open()) + { + cTypesDoc = XDocument.Load(cTypesStream); + } + + // 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 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.entries.FirstOrDefault(e => e.FullName.Contains("xl/calcChain.xml")); - if (calcChain != null) + if (calcChain != null && _calcChainContent.Length > 0) { var calcChainPathName = calcChain.FullName; - //calcChain.Delete(); - var calcChainEntry = outputFileArchive.zipFile.CreateEntry(calcChainPathName); - using (var calcChainStream = calcChainEntry.Open()) - { - CalcChainHelper.GenerateCalcChainSheet(calcChainStream, _calcChainContent.ToString()); - } + using var calcChainStream = calcChainEntry.Open(); + CalcChainHelper.GenerateCalcChainSheet(calcChainStream, _calcChainContent.ToString()); } else { - foreach (ZipArchiveEntry entry in originalArchive.Entries) - { - if (entry.FullName.Contains("xl/calcChain.xml")) - { - var newEntry = outputFileArchive.zipFile.CreateEntry(entry.FullName); - - // Copy the content of the original entry to the new entry - using (Stream originalEntryStream = entry.Open()) - using (Stream newEntryStream = newEntry.Open()) - { - originalEntryStream.CopyTo(newEntryStream); - } - } - } + // removing calcChain.xml entry from [Content_Types] and workbook.rels + wbRelsDoc.Root?.Elements() + .FirstOrDefault(x => x.Attribute("Target")?.Value.Contains("calcChain.xml") is true) + ?.Remove(); + + cTypesDoc.Root?.Elements() + .FirstOrDefault(x => x.Attribute("PartName")?.Value.Contains("calcChain.xml") is true) + ?.Remove(); + } + + var newWbRelsEntry = outputFileArchive.zipFile.CreateEntry(wbRelsEntry.FullName); + using (var newWbRelsEntryStream = newWbRelsEntry.Open()) + { + wbRelsDoc.Save(newWbRelsEntryStream); + } + + var newCTypesEntry = outputFileArchive.zipFile.CreateEntry(cTypesEntry.FullName); + using (var newCTypesEntryStream = newCTypesEntry.Open()) + { + cTypesDoc.Save(newCTypesEntryStream); } outputFileArchive.zipFile.Dispose(); @@ -180,4 +198,4 @@ public Task SaveAsByTemplateAsync(Stream templateStream, object value, Cancellat { return Task.Run(() => SaveAsByTemplate(templateStream, value), cancellationToken); } -} \ No newline at end of file +} diff --git a/tests/MiniExcelTests/SaveByTemplate/MiniExcelTemplateCalcChainTests.cs b/tests/MiniExcelTests/SaveByTemplate/MiniExcelTemplateCalcChainTests.cs new file mode 100644 index 00000000..0efb0763 --- /dev/null +++ b/tests/MiniExcelTests/SaveByTemplate/MiniExcelTemplateCalcChainTests.cs @@ -0,0 +1,114 @@ +using System.IO.Compression; +using System.Xml; +using ClosedXML.Excel; +using MiniExcelLibs.Tests.Utils; +using Xunit; + +namespace MiniExcelLibs.Tests.SaveByTemplate; + +/// +/// Regression tests for calcChain.xml handling and '$='-formula cell serialization in template +/// rendering. A template containing any formula carries a calcChain part whose entries point at +/// cell addresses; row insertion shifts formula cells, so the chain must be regenerated from the +/// rendered output — never left stale, and never written empty (a calcChain with zero <c> +/// entries is schema-invalid and Excel refuses to open the whole file). +/// +public class MiniExcelTemplateCalcChainTests +{ + [Fact] + public void 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 data = new() + { + ["title"] = "FooCompany", + ["items"] = new[] + { + new { Name = "A", Qty = 1 }, + new { Name = "B", Qty = 2 }, + } + }; + MiniExcel.SaveAsByTemplate(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 = reader.ReadToEnd(); + Assert.Contains(" data = new() + { + ["title"] = "FooCompany", + ["items"] = new[] + { + new { Name = "A", Qty = 1 }, + new { Name = "B", Qty = 2 }, + } + }; + MiniExcel.SaveAsByTemplate(path.ToString(), template.FilePath, data); + + using var zip = ZipFile.OpenRead(path.ToString()); + + // the formula cell: (two items shift row 7 to 8) with a namespaced child and no inlineStr type + var doc = new XmlDocument(); + using (var sheet = zip.GetEntry("xl/worksheets/sheet1.xml")!.Open()) + { + doc.Load(sheet); + } + + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("x", "http://schemas.openxmlformats.org/spreadsheetml/2006/main"); + var formulaCell = doc.SelectSingleNode("//x:c[@r='D8']", ns) as XmlElement; + Assert.NotNull(formulaCell); + Assert.Equal("SUM(B5:B6)", formulaCell.SelectSingleNode("x:f", ns)?.InnerText); + Assert.NotEqual("inlineStr", formulaCell.GetAttribute("t")); + + // and 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 = chainReader.ReadToEnd(); + Assert.Contains(@"r=""D8""", chain); + Assert.DoesNotContain(@"r=""B8""", chain); + } +}