diff --git a/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs b/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs index d63198c..3c98963 100644 --- a/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs +++ b/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs @@ -3,14 +3,57 @@ using ClosedXML.Excel; using NUnit.Framework; using System.Globalization; using System.IO; +using System.IO.Packaging; using System.Linq; using System.Threading; +using System.Xml.Linq; +using SaveOptions = ClosedXML.Excel.SaveOptions; namespace ClosedXML.Tests.Excel.ConditionalFormats { [TestFixture] public class ConditionalFormatTests { + [Test] + public void DataBarLowestHighestValueWritesMinMaxCfvoWithoutFormula() + { + // https://github.com/ClosedXML/ClosedXML/issues/2843 + // DataBar().LowestValue()/HighestValue() must emit the x14 cfvo nodes as + // type="min"/type="max" without an xm:f formula. The regression wrote them + // as type="num" locked to 0, which strict parsers (e.g. WPS) render wrong. + XNamespace x14 = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; + + using var ms = new MemoryStream(); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet("Sheet1"); + ws.Cell("A1").Value = 1; + ws.Cell("A2").Value = -1; + ws.Cell("A3").Value = 2; + ws.Range("A1:A3").AddConditionalFormat() + .DataBar(XLColor.Red) + .LowestValue() + .HighestValue(); + wb.SaveAs(ms); + } + + ms.Position = 0; + using var package = Package.Open(ms, FileMode.Open, FileAccess.Read); + var part = package.GetPart(new Uri("/xl/worksheets/sheet1.xml", UriKind.Relative)); + using var partStream = part.GetStream(); + var doc = XDocument.Load(partStream); + + var x14DataBar = doc.Descendants(x14 + "dataBar").Single(); + var cfvos = x14DataBar.Elements(x14 + "cfvo").ToList(); + + Assert.AreEqual(2, cfvos.Count); + Assert.AreEqual("min", cfvos[0].Attribute("type")?.Value); + Assert.AreEqual("max", cfvos[1].Attribute("type")?.Value); + Assert.IsFalse( + x14DataBar.Descendants().Any(e => e.Name.LocalName == "f"), + "x14 cfvo for lowest/highest value must not contain a formula."); + } + [Test] public void MaintainConditionalFormattingOrder() { diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx index fe7d0f2..b2ded29 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBarNegative.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBarNegative.xlsx index afae098..5b4b614 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBarNegative.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBarNegative.xlsx differ diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBars.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBars.xlsx index 236b6e3..24ed742 100644 Binary files a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBars.xlsx and b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBars.xlsx differ diff --git a/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs index 0ea59f6..20f90c5 100644 --- a/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs @@ -2,7 +2,6 @@ using ClosedXML.Extensions; using DocumentFormat.OpenXml.Office.Excel; using DocumentFormat.OpenXml.Office2010.Excel; using System.Collections.Generic; -using System.Linq; namespace ClosedXML.Excel { @@ -11,8 +10,8 @@ namespace ClosedXML.Excel private static readonly IReadOnlyDictionary CFValueToTypeMap = new Dictionary { - { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Max, ConditionalFormattingValueObjectTypeValues.AutoMax }, - { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Min, ConditionalFormattingValueObjectTypeValues.AutoMin }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Max, ConditionalFormattingValueObjectTypeValues.Max }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Min, ConditionalFormattingValueObjectTypeValues.Min }, { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Number, ConditionalFormattingValueObjectTypeValues.Numeric }, { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Percent, ConditionalFormattingValueObjectTypeValues.Percent }, { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Percentile, ConditionalFormattingValueObjectTypeValues.Percentile }, @@ -35,25 +34,8 @@ namespace ClosedXML.Excel ShowValue = !cf.ShowBarOnly }; - var cfMinType = cf.ContentTypes.TryGetValue(1, out var contentType1) - ? GetCFType(contentType1.ToOpenXml()) - : ConditionalFormattingValueObjectTypeValues.AutoMin; - var cfMin = new ConditionalFormattingValueObject { Type = cfMinType }; - if (cf.Values.Any() && cf.Values[1]?.Value != null) - { - cfMin.Type = ConditionalFormattingValueObjectTypeValues.Numeric; - cfMin.Append(new Formula() { Text = cf.Values[1].Value }); - } - - var cfMaxType = cf.ContentTypes.TryGetValue(2, out var contentType2) - ? GetCFType(contentType2.ToOpenXml()) - : ConditionalFormattingValueObjectTypeValues.AutoMax; - var cfMax = new ConditionalFormattingValueObject { Type = cfMaxType }; - if (cf.Values.Count >= 2 && cf.Values[2]?.Value != null) - { - cfMax.Type = ConditionalFormattingValueObjectTypeValues.Numeric; - cfMax.Append(new Formula() { Text = cf.Values[2].Value }); - } + var cfMin = BuildValueObject(cf, 1, ConditionalFormattingValueObjectTypeValues.AutoMin); + var cfMax = BuildValueObject(cf, 2, ConditionalFormattingValueObjectTypeValues.AutoMax); var barAxisColor = new BarAxisColor { Rgb = XLColor.Black.Color.ToHex() }; @@ -74,6 +56,55 @@ namespace ClosedXML.Excel return conditionalFormattingRule; } + /// + /// Builds a single x14 conditional formatting value object (cfvo) for a data bar threshold. + /// + /// The conditional format the data bar belongs to. + /// 1-based threshold index (1 = minimum, 2 = maximum). + /// + /// Type emitted for an automatic bound: autoMin for the minimum, autoMax for the maximum. + /// + /// + /// A threshold without an explicit content type is an automatic bound (autoMin/autoMax). + /// A min/max threshold is the lowest/highest value; both it and the automatic bound are + /// self-describing and carry no formula. Only value based thresholds (num/percent/percentile/ + /// formula) emit an xm:f formula. Writing a formula for a min/max threshold makes strict + /// readers such as WPS lock the bar to that value (see issue #2843). + /// + private static ConditionalFormattingValueObject BuildValueObject(IXLConditionalFormat cf, int index, ConditionalFormattingValueObjectTypeValues automaticType) + { + cf.Values.TryGetValue(index, out var value); + var formulaText = value?.Value; + + if (!cf.ContentTypes.TryGetValue(index, out var contentType)) + { + return new ConditionalFormattingValueObject { Type = automaticType }; + } + + var type = GetCFType(contentType.ToOpenXml()); + var valueObject = new ConditionalFormattingValueObject { Type = type }; + + if (type == ConditionalFormattingValueObjectTypeValues.Min || + type == ConditionalFormattingValueObjectTypeValues.Max) + { + // A min/max threshold with no value is an automatic bound (autoMin/autoMax); + // with a value it is the lowest/highest value. Neither carries a formula. + if (formulaText == null) + { + valueObject.Type = automaticType; + } + + return valueObject; + } + + if (formulaText != null) + { + valueObject.Append(new Formula { Text = formulaText }); + } + + return valueObject; + } + private static ConditionalFormattingValueObjectTypeValues GetCFType(DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues value) { return CFValueToTypeMap[value];