diff --git a/src/EPPlus/Core/Worksheet/XmlWriter/WorksheetXmlWriter.cs b/src/EPPlus/Core/Worksheet/XmlWriter/WorksheetXmlWriter.cs index 28f85fe222..28cea9635f 100644 --- a/src/EPPlus/Core/Worksheet/XmlWriter/WorksheetXmlWriter.cs +++ b/src/EPPlus/Core/Worksheet/XmlWriter/WorksheetXmlWriter.cs @@ -512,7 +512,7 @@ private void UpdateRowCellData(StreamWriter sw, string prefix) } else { - cache.Append($"<{cTag} r=\"{cse.CellAddress}\" s=\"{styleID}\"{ConvertUtil.GetCellType(v, true)}{mdAttr}>{GetFormulaValue(v, prefix)}"); + cache.Append($"<{cTag} r=\"{cse.CellAddress}\" s=\"{styleID}\"{ConvertUtil.GetCellType(v, true)}{mdAttr}><{fTag} t=\"shared\" si=\"{sfId}\" {mdAttrForFTag}/>{GetFormulaValue(v, prefix)}"); } } else @@ -646,12 +646,13 @@ private void UpdateRowCellData(StreamWriter sw, string prefix) private void SetCalcAttributesForArrayFormula(bool hasFlags, CellStoreEnumerator cse, ref string mdAttrForFTag) { + // #2572: Reset so attributes from a previous cell are not carried over to this cell. + mdAttrForFTag = ""; //Set calc attributes for array formula. We preserve them from load only at this point. if (hasFlags) { if (_ws._flags.Exists(cse.Row, cse.Column)) { - mdAttrForFTag = ""; if (_ws._flags.GetFlagValue(cse.Row, cse.Column, CellFlags.CellFlagAlwaysCalculateArray)) { mdAttrForFTag = $" aca=\"1\""; @@ -663,6 +664,7 @@ private void SetCalcAttributesForArrayFormula(bool hasFlags, CellStoreEnumerator } } } + private string GenerateArrayFormulaXml(string cTag, string fTag, string cellAddress, string styleID, string mdAttr, string mdAttrForFTag, string refAddress, object value, string formula, string prefix) { var cellType = ConvertUtil.GetCellType(value, true); @@ -1154,7 +1156,7 @@ private string UpdateConditionalFormattingAttributes( return cache.ToString(); } - private string GetXMLCFExt(string prefix, ExcelConditionalFormattingRule format, int i, int count) + private string GetXMLCFExt(string prefix, string mainPrefix, ExcelConditionalFormattingRule format, int i, int count) { var cache = new StringBuilder(); @@ -1349,7 +1351,7 @@ private string GetXMLCFExt(string prefix, ExcelConditionalFormattingRule format, cache.Append($"<{prefix}cfRule type=\"iconSet\" priority=\"{format.Priority}\" id=\"{{{uid}}}\">"); cache.Append($"<{prefix}iconSet "); - cache.Append($"iconSet=\"{iconSetString}\""); + cache.Append($"iconSet=\"{iconSetString}\""); if (showValue == false) { @@ -1514,7 +1516,8 @@ private string GetXMLCFExt(string prefix, ExcelConditionalFormattingRule format, if (format.Style.HasValue) { - WriteStyle(cache, prefix, format); + // Sc1083: The dxf children belong to the main schema, so they need the main prefix. + WriteStyle(cache, prefix, mainPrefix, format); } } cache.Append($""); @@ -1578,7 +1581,8 @@ private string UpdateExtLstConditionalFormatting(string mainPrefix) foreach (var rule in pivotCFs) { - cache.Append(GetXMLCFExt(prefix, rule, 0, 1)); + // Sc1083: Pass mainPrefix so elements from the main schema get the worksheet's namespace prefix. + cache.Append(GetXMLCFExt(prefix, mainPrefix, rule, 0, 1)); } foreach (var ruleList in addressDict) @@ -1586,7 +1590,8 @@ private string UpdateExtLstConditionalFormatting(string mainPrefix) for (int i = 0; i < ruleList.Value.Count; i++) { var format = ruleList.Value[i]; - cache.Append(GetXMLCFExt(prefix, ruleList.Value[i], i, ruleList.Value.Count)); + // Sc1083: Pass mainPrefix so elements from the main schema get the worksheet's namespace prefix. + cache.Append(GetXMLCFExt(prefix, mainPrefix, ruleList.Value[i], i, ruleList.Value.Count)); } } @@ -1637,11 +1642,11 @@ string WriteColorOption(string nodeName, ExcelDxfColor color) return returnString; } - private string WriteCfIcon(ExcelConditionalFormattingIconDataBarValue icon, bool gteCheck = true) + private string WriteCfIcon(string prefix, ExcelConditionalFormattingIconDataBarValue icon, bool gteCheck = true) { StringBuilder cache = new StringBuilder(); - cache.Append($""); } - void WriteStyle(StringBuilder cache, string prefix, ExcelConditionalFormattingRule format) + void WriteStyle(StringBuilder cache, string prefix, string mainPrefix, ExcelConditionalFormattingRule format) { + // #2572: dxf itself is in the x14 namespace (prefix), but its children belong to the main schema (mainPrefix). if (format.Style.HasValue) { cache.Append($"<{prefix}dxf>"); if (format.Style.Font.HasValue) { - cache.Append($""); + cache.Append($"<{mainPrefix}font>"); if (format.Style.Font.Bold == true) { - cache.Append($""); + cache.Append($"<{mainPrefix}b/>"); } if (format.Style.Font.Italic == true) { if (format.Style.Font.Bold == false || format.Style.Font.Bold == null) { - cache.Append(""); + cache.Append($"<{mainPrefix}b val=\"0\"/>"); } - cache.Append($""); + cache.Append($"<{mainPrefix}i/>"); } if (format.Style.Font.Bold == false && format.Style.Font.Italic == false) { - cache.Append(""); - cache.Append(""); + cache.Append($"<{mainPrefix}b val=\"0\"/>"); + cache.Append($"<{mainPrefix}i val=\"0\"/>"); } if (format.Style.Font.Strike == true) { - cache.Append($""); + cache.Append($"<{mainPrefix}strike/>"); } if (format.Style.Font.Underline.HasValue == true) { - cache.Append($""); + cache.Append($""); } if (format.Style.NumberFormat.HasValue) { var numberFormat = format.Style.NumberFormat.Format.EncodeXMLAttribute(); - cache.Append($""); } if (format.Style.Fill.HasValue) { - cache.Append(""); + cache.Append($"<{mainPrefix}fill>"); switch (format.Style.Fill.Style) { @@ -2085,28 +2093,28 @@ void WriteStyle(StringBuilder cache, string prefix, ExcelConditionalFormattingRu if (format.Style.Fill.PatternType != null && format.Style.Fill.PatternType != ExcelFillStyle.None) { - cache.Append($""); + cache.Append($"<{mainPrefix}patternFill patternType=\"{format.Style.Fill.PatternType.ToEnumString()}\">"); } else { - cache.Append(""); + cache.Append($"<{mainPrefix}patternFill>"); } if (format.Style.Fill.PatternColor.HasValue) { - cache.Append(WriteColorOption("fgColor", format.Style.Fill.PatternColor)); + cache.Append(WriteColorOption($"{mainPrefix}fgColor", format.Style.Fill.PatternColor)); } if (format.Style.Fill.BackgroundColor.HasValue) { - cache.Append(WriteColorOption("bgColor", format.Style.Fill.BackgroundColor)); + cache.Append(WriteColorOption($"{mainPrefix}bgColor", format.Style.Fill.BackgroundColor)); } - cache.Append(""); + cache.Append($""); break; case Style.eDxfFillStyle.GradientFill: - cache.Append(""); - cache.Append(""); + cache.Append($"<{mainPrefix}stop position=\"0\">"); - cache.Append(WriteColorOption("color", format.Style.Fill.Gradient.Colors[0].Color)); + cache.Append(WriteColorOption($"{mainPrefix}color", format.Style.Fill.Gradient.Colors[0].Color)); - cache.Append(""); - cache.Append(""); + cache.Append($""); + cache.Append($"<{mainPrefix}stop position=\"1.0\">"); - cache.Append(WriteColorOption("color", format.Style.Fill.Gradient.Colors[1].Color)); + cache.Append(WriteColorOption($"{mainPrefix}color", format.Style.Fill.Gradient.Colors[1].Color)); - cache.Append(""); + cache.Append($""); - cache.Append(""); + cache.Append($""); break; } - cache.Append(""); + cache.Append($""); } if (format.Style.Border.HasValue) { - cache.Append(""); + cache.Append($"<{mainPrefix}border>"); if (format.Style.Border.Left.HasValue) { - cache.Append($""); - cache.Append(WriteColorOption("color", format.Style.Border.Left.Color)); - cache.Append(""); + cache.Append($"<{mainPrefix}left style=\"{format.Style.Border.Left.Style.ToString().ToLower()}\">"); + cache.Append(WriteColorOption($"{mainPrefix}color", format.Style.Border.Left.Color)); + cache.Append($""); } if (format.Style.Border.Right.HasValue) { - cache.Append($""); - cache.Append(WriteColorOption("color", format.Style.Border.Right.Color)); - cache.Append(""); + cache.Append($"<{mainPrefix}right style=\"{format.Style.Border.Right.Style.ToString().ToLower()}\">"); + cache.Append(WriteColorOption($"{mainPrefix}color", format.Style.Border.Right.Color)); + cache.Append($""); } if (format.Style.Border.Top.HasValue) { - cache.Append($""); - cache.Append(WriteColorOption("color", format.Style.Border.Top.Color)); - cache.Append(""); + cache.Append($"<{mainPrefix}top style=\"{format.Style.Border.Top.Style.ToString().ToLower()}\">"); + cache.Append(WriteColorOption($"{mainPrefix}color", format.Style.Border.Top.Color)); + cache.Append($""); } if (format.Style.Border.Bottom.HasValue) { - cache.Append($""); - cache.Append(WriteColorOption("color", format.Style.Border.Bottom.Color)); - cache.Append(""); + cache.Append($"<{mainPrefix}bottom style=\"{format.Style.Border.Bottom.Style.ToString().ToLower()}\">"); + cache.Append(WriteColorOption($"{mainPrefix}color", format.Style.Border.Bottom.Color)); + cache.Append($""); } - cache.Append(""); - cache.Append(""); - cache.Append(""); + cache.Append($"<{mainPrefix}vertical/>"); + cache.Append($"<{mainPrefix}horizontal/>"); + cache.Append($""); } cache.Append($""); diff --git a/src/EPPlusTest/Core/Worksheet/WorksheetXmlWriterTests.cs b/src/EPPlusTest/Core/Worksheet/WorksheetXmlWriterTests.cs new file mode 100644 index 0000000000..84b7306642 --- /dev/null +++ b/src/EPPlusTest/Core/Worksheet/WorksheetXmlWriterTests.cs @@ -0,0 +1,181 @@ +/************************************************************************************************* + Required Notice: Copyright (C) EPPlus Software AB. + This software is licensed under PolyForm Noncommercial License 1.0.0 + and may only be used for noncommercial purposes + https://polyformproject.org/licenses/noncommercial/1.0.0/ + + A commercial license to use this software can be purchased at https://epplussoftware.com + ************************************************************************************************* + Date Author Change + ************************************************************************************************* + 10/12/2026 EPPlus Software AB Initial release, tests for issue #2572 + *************************************************************************************************/ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using OfficeOpenXml.ConditionalFormatting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; + +namespace EPPlusTest.Core.Worksheet +{ + /// + /// Tests for the XML written by the worksheet XML writer. + /// The prefix tests use a worksheet where the main schema has a namespace prefix (x:) and no default namespace, + /// as written by the Open XML SDK. Any element written without the prefix ends up in no namespace. + /// + [TestClass] + public class WorksheetXmlWriterTests : TestBase + { + private const string PrefixedWorkbook = "sc1083-sdk.xlsx"; + private const string CalcFlagsWorkbook = "sc1083-flags.xlsx"; + private const string WorksheetPart = "xl/worksheets/sheet1.xml"; + private const string SchemaMainX14 = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; + + /// + /// Issue #2572: Shared formula children were written as <f> without the namespace prefix, + /// so Excel treated the cells as constants. + /// + [TestMethod] + public void SharedFormulaChildKeepsNamespacePrefix() + { + const string outputName = "WorksheetXmlWriter_SharedFormulaChild.xlsx"; + using (var p = OpenTemplatePackage(PrefixedWorkbook)) + { + SaveWorkbook(outputName, p); + } + + var doc = LoadPartXml(outputName, WorksheetPart); + var nsm = CreateNamespaceManager(doc); + + //Guard: the shared formula children must be written, otherwise the test does not exercise the shared formula path. + Assert.IsNotNull(doc.SelectSingleNode("//m:c[@r='C4']/m:f[@t='shared']", nsm), "C4 has no shared formula in the main namespace."); + Assert.IsNotNull(doc.SelectSingleNode("//m:c[@r='C5']/m:f[@t='shared']", nsm), "C5 has no shared formula in the main namespace."); + AssertNoElementsWithoutNamespace(doc); + } + + /// + /// Issue #2572: Icon set cfvo elements were written without the namespace prefix, + /// so Excel reported the workbook as corrupt. + /// + [TestMethod] + public void IconSetCfvoKeepsNamespacePrefix() + { + const string outputName = "WorksheetXmlWriter_IconSetCfvo.xlsx"; + using (var p = OpenTemplatePackage(PrefixedWorkbook)) + { + var ws = p.Workbook.Worksheets[0]; + ws.ConditionalFormatting.AddThreeIconSet(ws.Cells["A2:A5"], eExcelconditionalFormatting3IconsSetType.Arrows); + SaveWorkbook(outputName, p); + } + + var doc = LoadPartXml(outputName, WorksheetPart); + + //Guard: the icon set must be written, otherwise the test does not exercise the cfvo path. + Assert.IsTrue(doc.GetElementsByTagName("iconSet", ExcelPackage.schemaMain).Count > 0, "No iconSet element was written."); + AssertNoElementsWithoutNamespace(doc); + } + + /// + /// Issue #2572: The children of x14:dxf in extLst conditional formatting were written without the main namespace prefix, + /// so Excel silently dropped the format of the rule. + /// + [TestMethod] + public void ExtLstDxfChildrenKeepNamespacePrefix() + { + const string outputName = "WorksheetXmlWriter_ExtLstDxf.xlsx"; + using (var p = OpenTemplatePackage(PrefixedWorkbook)) + { + var ws = p.Workbook.Worksheets[0]; + var ws2 = p.Workbook.Worksheets.Add("Sheet2"); + ws2.Cells["A1"].Value = 3; + + //A reference to another worksheet forces the rule into extLst (x14). + var cf = ws.ConditionalFormatting.AddExpression(ws.Cells["A2:A5"]); + cf.Formula = "A2>Sheet2!$A$1"; + cf.Style.Font.Bold = true; + cf.Style.Fill.BackgroundColor.Color = System.Drawing.Color.Yellow; + SaveWorkbook(outputName, p); + } + + var doc = LoadPartXml(outputName, WorksheetPart); + + //Guard: the rule must be written to extLst, otherwise the test does not exercise the dxf path. + Assert.IsTrue(doc.GetElementsByTagName("conditionalFormatting", SchemaMainX14).Count > 0, "The rule was not written to extLst."); + Assert.IsTrue(doc.GetElementsByTagName("dxf", SchemaMainX14).Count > 0, "No x14:dxf element was written."); + AssertNoElementsWithoutNamespace(doc); + } + + /// + /// Issue #2572: The aca and ca attributes of an array formula leaked to the following formula cells, + /// as the attribute string was only reset for cells with flags. + /// + [TestMethod] + public void CalcAttributesDoNotLeakToNextFormulaCell() + { + const string outputName = "WorksheetXmlWriter_CalcAttributes.xlsx"; + using (var p = OpenTemplatePackage(CalcFlagsWorkbook)) + { + SaveWorkbook(outputName, p); + } + + var doc = LoadPartXml(outputName, WorksheetPart); + var nsm = CreateNamespaceManager(doc); + + //Guard: the array formula must keep its calc attributes, otherwise there is nothing that can leak. + var arrayFormula = doc.SelectSingleNode("//m:c[@r='D2']/m:f", nsm) as XmlElement; + Assert.IsNotNull(arrayFormula, "D2 has no formula element."); + Assert.AreEqual("1", arrayFormula.GetAttribute("aca"), "D2 lost its aca attribute on load or save."); + + foreach (var address in new[] { "C3", "C4", "C5" }) + { + var formula = doc.SelectSingleNode($"//m:c[@r='{address}']/m:f", nsm) as XmlElement; + Assert.IsNotNull(formula, $"{address} has no formula element."); + Assert.IsFalse(formula.HasAttribute("aca"), $"{address} inherited aca from a previous cell."); + Assert.IsFalse(formula.HasAttribute("ca"), $"{address} inherited ca from a previous cell."); + } + } + + private static XmlNamespaceManager CreateNamespaceManager(XmlDocument doc) + { + var nsm = new XmlNamespaceManager(doc.NameTable); + nsm.AddNamespace("m", ExcelPackage.schemaMain); + return nsm; + } + + private static XmlDocument LoadPartXml(string fileName, string partName) + { + using (var fileStream = File.OpenRead(_worksheetPath + fileName)) + using (var zipStream = new OfficeOpenXml.Packaging.Ionic.Zip.ZipInputStream(fileStream)) + { + OfficeOpenXml.Packaging.Ionic.Zip.ZipEntry entry; + while ((entry = zipStream.GetNextEntry()) != null) + { + if (entry.FileName.Equals(partName, StringComparison.OrdinalIgnoreCase)) + { + var doc = new XmlDocument(); + doc.Load(zipStream); + return doc; + } + } + } + Assert.Fail($"Part {partName} is missing in {fileName}"); + return null; + } + + private static void AssertNoElementsWithoutNamespace(XmlDocument doc) + { + var offenders = new List(); + foreach (XmlElement element in doc.GetElementsByTagName("*")) + { + if (string.IsNullOrEmpty(element.NamespaceURI)) + { + var xml = element.OuterXml; + offenders.Add(xml.Length > 120 ? xml.Substring(0, 120) : xml); + } + } + Assert.AreEqual(0, offenders.Count, "Elements without namespace: " + string.Join(" | ", offenders)); + } + } +} \ No newline at end of file diff --git a/src/EPPlusTest/EPPlus.Test.csproj b/src/EPPlusTest/EPPlus.Test.csproj index f65652daf8..5d47347490 100644 --- a/src/EPPlusTest/EPPlus.Test.csproj +++ b/src/EPPlusTest/EPPlus.Test.csproj @@ -251,6 +251,12 @@ PreserveNewest + + + PreserveNewest + + + PreserveNewest PreserveNewest diff --git a/src/EPPlusTest/Workbooks/sc1083-flags.xlsx b/src/EPPlusTest/Workbooks/sc1083-flags.xlsx new file mode 100644 index 0000000000..d430028c69 Binary files /dev/null and b/src/EPPlusTest/Workbooks/sc1083-flags.xlsx differ diff --git a/src/EPPlusTest/Workbooks/sc1083-sdk.xlsx b/src/EPPlusTest/Workbooks/sc1083-sdk.xlsx new file mode 100644 index 0000000000..3556173502 Binary files /dev/null and b/src/EPPlusTest/Workbooks/sc1083-sdk.xlsx differ