Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions README-NuGet.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
20 changes: 16 additions & 4 deletions src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.Impl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <f> 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> 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);
}
}
Expand Down Expand Up @@ -1075,7 +1086,8 @@ private static void ReplaceSharedStringsToStr(IDictionary<int, string> sharedStr
c.AppendChild(isNode);

c.RemoveAttribute("t");
c.SetAttribute("t", "inlineStr"); }
c.SetAttribute("t", "inlineStr");
}
}
}

Expand Down Expand Up @@ -1514,4 +1526,4 @@ private static bool EvaluateStatement(object tagValue, string comparisonOperator

return false;
}
}
}
90 changes: 54 additions & 36 deletions src/MiniExcel/SaveByTemplate/ExcelOpenXmlTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;

namespace MiniExcelLibs.OpenXml.SaveByTemplate;

Expand Down Expand Up @@ -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
Expand All @@ -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);

Expand All @@ -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)
Expand All @@ -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 <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.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();
Expand All @@ -180,4 +198,4 @@ public Task SaveAsByTemplateAsync(Stream templateStream, object value, Cancellat
{
return Task.Run(() => SaveAsByTemplate(templateStream, value), cancellationToken);
}
}
}
114 changes: 114 additions & 0 deletions tests/MiniExcelTests/SaveByTemplate/MiniExcelTemplateCalcChainTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.IO.Compression;
using System.Xml;
using ClosedXML.Excel;
using MiniExcelLibs.Tests.Utils;
using Xunit;

namespace MiniExcelLibs.Tests.SaveByTemplate;

/// <summary>
/// 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 &lt;c&gt;
/// entries is schema-invalid and Excel refuses to open the whole file).
/// </summary>
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<string, object?> 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("<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 void 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 },
}
};
MiniExcel.SaveAsByTemplate(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
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);
}
}
Loading