From 12d7dc07aed277f4615756e2aa756390ec060e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vitou=C5=A1?= Date: Tue, 28 Jul 2026 10:57:07 +0200 Subject: [PATCH] Fix template calcChain regeneration and $= formula cells (v2 / MiniExcel.OpenXml) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Porting the v1.x template-render fixes to the v2 OpenXml line, where the engine moved to src/MiniExcel.OpenXml/ and a LINQ-to-XML rewrite. Two defects made Excel reject an otherwise-valid rendered package: - $= formula cells kept their source t="inlineStr" attribute after the inline string was replaced with an child — a cell typed inlineStr with no is schema-invalid. The attribute is now removed. - The regenerated calcChain entry took the cell address from the running child-index, which is wrong for sparse rows (cells with no content are not emitted, so position != column). The address is now read from the cell's own r attribute. - The template's own calcChain was reused/copied even though row insertion shifts every formula cell, leaving stale entries; and an empty calcChain (no rendered formulas) was written, which is schema-invalid. The chain is now regenerated only when formulas were rendered and dropped entirely otherwise — Excel rebuilds it on open. The namespace fix from the v1 change is not needed here: v2's XElement-based serialization already emits it in the spreadsheetml namespace. --- .../Templates/OpenXmlTemplate.Impl.cs | 13 +++++-- .../Templates/OpenXmlTemplate.cs | 34 +++++-------------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs index 09009ffe..9571e9eb 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.Impl.cs @@ -936,8 +936,17 @@ private void ProcessFormulas(StringBuilder rowXml, int rowIndex) fNode.SetValue(str.Value[2..]); str.AddBeforeSelf(fNode); str.Remove(); - - var celRef = CellReferenceConverter.GetCellFromCoordinates(index, rowIndex); + // the cell no longer holds an inline string; keeping t="inlineStr" without an + // child is schema-invalid and Excel rejects the package + 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 cellRef = cell.Attribute("r")?.Value; + var columnLetters = cellRef is null ? "" : new string(cellRef.TakeWhile(char.IsLetter).ToArray()); + var celRef = string.IsNullOrEmpty(columnLetters) + ? CellReferenceConverter.GetCellFromCoordinates(index, rowIndex) + : $"{columnLetters}{rowIndex}"; _calcChainCellRefs.Add(celRef); } } diff --git a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs index 60888c6d..09b76f20 100644 --- a/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs +++ b/src/MiniExcel.OpenXml/Templates/OpenXmlTemplate.cs @@ -158,11 +158,16 @@ 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 entries is + // schema-invalid and Excel rejects the whole package. Excel rebuilds the chain on open, so + // dropping it is always safe. (The stale-template copy that used to run when no chain entry + // was created here is intentionally gone for the same reason.) + var calcChain = outputFileArchive.EntryCollection.FirstOrDefault(e => e.FullName.TrimStart('/').Equals(ExcelFileNames.CalcChain, StringComparison.OrdinalIgnoreCase)); - if (calcChain is not null) + if (calcChain is not null && _calcChainContent.Length > 0) { var calcChainEntry = outputFileArchive.ZipFile.CreateEntry(calcChain.FullName); var calcChainStream = await calcChainEntry.OpenAsync(cancellationToken).ConfigureAwait(false); @@ -170,29 +175,6 @@ await originalEntryStream.CopyToAsync(newEntryStream 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); - } - } - } #if NET10_0_OR_GREATER await outputFileArchive.ZipFile.DisposeAsync().ConfigureAwait(false);