fix: write data bar cfvo as min/max instead of num for lowest/highest value

DataBar().LowestValue()/HighestValue() emitted the x14 extension cfvo nodes as type="num" with a formula of 0 instead of type="min"/"max". Strict readers such as WPS Spreadsheets honor that value and lock the bar thresholds to 0, so the data bar renders incorrectly.

The extension converter unconditionally overwrote the cfvo type with Numeric whenever a threshold value was present. LowestValue()/HighestValue() store a placeholder "0", so their automatic min/max bounds were turned into fixed numeric thresholds; the same override also collapsed percent, percentile and formula thresholds to num.

Each cfvo is now built from its content type: automatic bounds stay autoMin/autoMax, lowest/highest bounds become min/max with no formula, and value based thresholds keep their type and emit the formula.

Fixes #2843
This commit is contained in:
dualfroz committed 2026-09-02 11:15:38 +02:00
1 parent c8aec98c23
commit 08d598d941
5 files changed
+96 -22

No files matched your search

@@ -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()
{
@@ -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<DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues, ConditionalFormattingValueObjectTypeValues> CFValueToTypeMap =
new Dictionary<DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues, ConditionalFormattingValueObjectTypeValues>
{
{ 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;
}
/// <summary>
/// Builds a single x14 conditional formatting value object (cfvo) for a data bar threshold.
/// </summary>
/// <param name="cf">The conditional format the data bar belongs to.</param>
/// <param name="index">1-based threshold index (1 = minimum, 2 = maximum).</param>
/// <param name="automaticType">
/// Type emitted for an automatic bound: autoMin for the minimum, autoMax for the maximum.
/// </param>
/// <remarks>
/// 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).
/// </remarks>
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];