commit 8d5ee560812dd3304ea6f423b974a84e6d454e93 Author: upstream Date: Wed Sep 2 11:15:38 2026 +0200 Upstream ClosedXML/ClosedXML state of the touched files before f8af6b544a diff --git a/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs b/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs new file mode 100644 index 0000000..d63198c --- /dev/null +++ b/ClosedXML.Tests/Excel/ConditionalFormats/ConditionalFormatTests.cs @@ -0,0 +1,233 @@ +using System; +using ClosedXML.Excel; +using NUnit.Framework; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; + +namespace ClosedXML.Tests.Excel.ConditionalFormats +{ + [TestFixture] + public class ConditionalFormatTests + { + [Test] + public void MaintainConditionalFormattingOrder() + { + // The input file contains duplicates of same dxf and thus the output also contains some duplicate dxf. + TestHelper.LoadSaveAndCompare( + @"Other\StyleReferenceFiles\ConditionalFormattingOrder\inputfile.xlsx", + @"Other\StyleReferenceFiles\ConditionalFormattingOrder\ConditionalFormattingOrder.xlsx"); + } + + [TestCase(true, 7)] + [TestCase(false, 8)] + public void SaveOptionAffectsConsolidationConditionalFormatRanges(bool consolidateConditionalFormatRanges, int expectedCount) + { + var options = new SaveOptions + { + ConsolidateConditionalFormatRanges = consolidateConditionalFormatRanges + }; + + var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); + + ws.Range("D2:D3").AddConditionalFormat().DataBar(XLColor.Red).LowestValue().HighestValue(); + ws.Range("B2:B3").AddConditionalFormat().DataBar(XLColor.Red).LowestValue().HighestValue(); + ws.Range("E2:E6").AddConditionalFormat().ColorScale().LowestValue(XLColor.Red).HighestValue(XLColor.Blue); + ws.Range("F2:F6").AddConditionalFormat().ColorScale().LowestValue(XLColor.Red).HighestValue(XLColor.Blue); + ws.Range("G2:G7").AddConditionalFormat().WhenIsUnique().Fill.SetBackgroundColor(XLColor.Blue); + ws.Range("H2:H7").AddConditionalFormat().WhenIsUnique().Fill.SetBackgroundColor(XLColor.Blue); + ws.Range("I2:I6").AddConditionalFormat().WhenContains("test"); + ws.Range("J2:J6").AddConditionalFormat().WhenContains("test"); + using (var ms = new MemoryStream()) + { + wb.SaveAs(ms, options); + var wb_saved = new XLWorkbook(ms); + Assert.AreEqual(expectedCount, wb_saved.Worksheet("Sheet").ConditionalFormats.Count()); + } + } + + [TestCase(true, 1)] + [TestCase(false, 2)] + public void SaveOptionAffectsConsolidationDataValidationRanges(bool consolidateDataValidationRanges, int expectedCount) + { + var options = new SaveOptions + { + ConsolidateDataValidationRanges = consolidateDataValidationRanges + }; + + var wb = new XLWorkbook(); + var ws = wb.AddWorksheet("Sheet"); + ws.Range("C2:C5").CreateDataValidation().Decimal.Between(1, 5); + ws.Range("D2:D5").CreateDataValidation().Decimal.Between(1, 5); + + using (var ms = new MemoryStream()) + { + wb.SaveAs(ms, options); + var wb_saved = new XLWorkbook(ms); + Assert.AreEqual(expectedCount, wb_saved.Worksheet("Sheet").DataValidations.Count()); + } + } + + [TestCase("en-US")] + [TestCase("fr-FR")] + [TestCase("ru-RU")] + public void SaveConditionalFormat_CultureIndependent(string culture) + { + using (var ms = new MemoryStream()) + { + var expectedValue = 1.5; + Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(culture); + using (var wb = new XLWorkbook()) + { + var ws = wb.AddWorksheet(); + var i = 1; + ws.Cell(i++, 1).AddConditionalFormat().WhenEquals(expectedValue).Fill.SetBackgroundColor(XLColor.Red); + ws.Cell(i++, 1).AddConditionalFormat().WhenNotEquals(expectedValue).Fill.SetBackgroundColor(XLColor.Red); + ws.Cell(i++, 1).AddConditionalFormat().WhenGreaterThan(expectedValue).Fill.SetBackgroundColor(XLColor.Red); + ws.Cell(i++, 1).AddConditionalFormat().WhenLessThan(expectedValue).Fill.SetBackgroundColor(XLColor.Red); + ws.Cell(i++, 1).AddConditionalFormat().WhenEqualOrGreaterThan(expectedValue).Fill.SetBackgroundColor(XLColor.Red); + ws.Cell(i++, 1).AddConditionalFormat().WhenEqualOrLessThan(expectedValue).Fill.SetBackgroundColor(XLColor.Red); + ws.Cell(i++, 1).AddConditionalFormat().WhenBetween(expectedValue, expectedValue).Fill.SetBackgroundColor(XLColor.Red); + ws.Cell(i++, 1).AddConditionalFormat().WhenNotBetween(expectedValue, expectedValue).Fill.SetBackgroundColor(XLColor.Red); + + wb.SaveAs(ms); + } + + using (var wb = new XLWorkbook(ms)) + { + var ws = wb.Worksheets.First(); + + var conditionalFormatValues = ws.ConditionalFormats + .SelectMany(cf => cf.Values.Values) + .Select(v => v.Value) + .Distinct(); + + Assert.AreEqual(1, conditionalFormatValues.Count()); + Assert.AreEqual("1.5", conditionalFormatValues.Single()); + } + } + } + + [Test] + public void CellIs_type_reads_only_required_formula_arguments() + { + // The CellIs uses formula tags as arguments. Some producers generate extra empty + // formula tags and ClosedXml should be able to load CellIs conditional formatting + // with such extra tags without an exception. The test file has been modified to + // include extra formula tags and test checks that extra tags are ignored. + TestHelper.LoadAndAssert((_, ws) => + { + AssertFormulaArgs(ws, XLCFOperator.Between, "$D$2", "$E$2"); + AssertFormulaArgs(ws, XLCFOperator.NotBetween, "$D$3", "$E$3"); + AssertFormulaArgs(ws, XLCFOperator.GreaterThan, "$D$4"); + AssertFormulaArgs(ws, XLCFOperator.LessThan, "$D$5"); + AssertFormulaArgs(ws, XLCFOperator.Equal, "$D$6"); + }, @"Other\ConditionalFormats\Extra_formulas_CellIs_type.xlsx"); + + static void AssertFormulaArgs(IXLWorksheet ws, XLCFOperator cfOperator, params string[] expectedFormulas) + { + var cf = ws.ConditionalFormats.Single(cf => cf.ConditionalFormatType == XLConditionalFormatType.CellIs && cf.Operator == cfOperator); + Assert.AreEqual(expectedFormulas.Length, cf.Values.Count); + CollectionAssert.AreEqual(expectedFormulas, cf.Values.Select(v => v.Value.Value)); + } + } + + [Test] + public void Expression_type_skips_empty_formula_tags() + { + // The Expression uses formula tag as arguments. Some producers generate extra empty + // formula tags and ClosedXml should be able to load Expression conditional formatting + // with such extra tags without an exception. The test file has been modified to + // include extra formula tags and test checks that extra tags are ignored. + TestHelper.LoadAndAssert((_, ws) => + { + AssertFormulaArgs(ws, "A1:A1", "$C$1=5"); + AssertFormulaArgs(ws, "A2:A2", "$C$2=4"); + }, @"Other\ConditionalFormats\Extra_formulas_Expression_type.xlsx"); + + static void AssertFormulaArgs(IXLWorksheet ws, string range, string expectedFormula) + { + var cf = ws.ConditionalFormats.Single(cf => cf.ConditionalFormatType == XLConditionalFormatType.Expression && cf.Range.RangeAddress.ToString() == range); + Assert.AreEqual(1, cf.Values.Count); + CollectionAssert.AreEqual(expectedFormula, cf.Values[1].Value); + } + } + + [Test] + public void Range_setter_throws_on_range_from_different_worksheet() + { + using var wb = new XLWorkbook(); + var ws1 = wb.AddWorksheet(); + var cf = ws1.Range("A1").AddConditionalFormat(); + var ws2 = wb.AddWorksheet(); + var differentSheetRange = ws2.Range("B5"); + + Assert.Throws(() => cf.Range = differentSheetRange); + } + + [Test] + public void Ranges_setter_throws_on_range_from_different_worksheet() + { + using var wb = new XLWorkbook(); + var ws1 = wb.AddWorksheet(); + var cf = ws1.Range("A1").AddConditionalFormat(); + + var ranges = ws1.Ranges("B1"); + var differentSheetRange = wb.AddWorksheet().Range("C1"); + ranges.Add(differentSheetRange); + + Assert.That(() => cf.Ranges = ranges, Throws.TypeOf().With.Message.Contains("must be from worksheet")); + } + + [Test] + public void Ranges_setter_throws_on_empty_ranges() + { + using var wb = new XLWorkbook(); + var ws1 = wb.AddWorksheet(); + var cf = ws1.Range("A1").AddConditionalFormat(); + + var emptyRanges = ws1.Ranges(""); + + Assert.That(() => cf.Ranges = emptyRanges, Throws.TypeOf().With.Message.Contains("empty")); + } + + [Test] + public void Text_formats_are_loaded_as_value() + { + // Issue #2690: Ensure that CF for text are loaded as a value and not as a formula. + TestHelper.CreateSaveLoadAssert( + (_, ws) => + { + ws.AddConditionalFormat().WhenContains("Contains") + .Fill.SetBackgroundColor(XLColor.Red); + + ws.AddConditionalFormat().WhenNotContains("Not Contains") + .Fill.SetBackgroundColor(XLColor.Green); + + ws.AddConditionalFormat().WhenStartsWith("Starts With") + .Fill.SetBackgroundColor(XLColor.Blue); + + ws.AddConditionalFormat().WhenEndsWith("Ends With") + .Fill.SetBackgroundColor(XLColor.Black); + }, + (_, ws) => + { + AssertTextCfValue(ws, XLConditionalFormatType.ContainsText, "Contains"); + AssertTextCfValue(ws, XLConditionalFormatType.NotContainsText, "Not Contains"); + AssertTextCfValue(ws, XLConditionalFormatType.StartsWith, "Starts With"); + AssertTextCfValue(ws, XLConditionalFormatType.EndsWith, "Ends With"); + }); + return; + + static void AssertTextCfValue(IXLWorksheet ws, XLConditionalFormatType type, string text) + { + var cf = ws.ConditionalFormats.Single(x => x.ConditionalFormatType == type); + Assert.AreEqual(1, cf.Values.Count); + Assert.AreEqual(text, cf.Values[1].Value); + Assert.IsFalse(cf.Values[1].IsFormula); + } + } + } +} diff --git a/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx b/ClosedXML.Tests/Resource/Examples/ConditionalFormatting/CFDataBar.xlsx new file mode 100644 index 0000000..fe7d0f2 Binary files /dev/null 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 new file mode 100644 index 0000000..afae098 Binary files /dev/null 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 new file mode 100644 index 0000000..236b6e3 Binary files /dev/null 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 new file mode 100644 index 0000000..0ea59f6 --- /dev/null +++ b/ClosedXML/Excel/ConditionalFormats/Save/XLCFDataBarConverterExtension.cs @@ -0,0 +1,82 @@ +using ClosedXML.Extensions; +using DocumentFormat.OpenXml.Office.Excel; +using DocumentFormat.OpenXml.Office2010.Excel; +using System.Collections.Generic; +using System.Linq; + +namespace ClosedXML.Excel +{ + internal class XLCFDataBarConverterExtension : IXLCFConverterExtension + { + 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.Number, ConditionalFormattingValueObjectTypeValues.Numeric }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Percent, ConditionalFormattingValueObjectTypeValues.Percent }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Percentile, ConditionalFormattingValueObjectTypeValues.Percentile }, + { DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues.Formula, ConditionalFormattingValueObjectTypeValues.Formula }, + }; + + public ConditionalFormattingRule Convert(IXLConditionalFormat cf, XLWorkbook.SaveContext context) + { + ConditionalFormattingRule conditionalFormattingRule = new ConditionalFormattingRule() + { + Type = DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValues.DataBar, + Id = ((XLConditionalFormat)cf).Id.WrapInBraces() + }; + + DataBar dataBar = new DataBar() + { + MinLength = 0, + MaxLength = 100, + Gradient = true, + 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 barAxisColor = new BarAxisColor { Rgb = XLColor.Black.Color.ToHex() }; + + var negativeFillColor = new NegativeFillColor { Rgb = cf.Colors[1].Color.ToHex() }; + if (cf.Colors.Count == 2) + { + negativeFillColor = new NegativeFillColor { Rgb = cf.Colors[2].Color.ToHex() }; + } + + dataBar.Append(cfMin); + dataBar.Append(cfMax); + + dataBar.Append(negativeFillColor); + dataBar.Append(barAxisColor); + + conditionalFormattingRule.Append(dataBar); + + return conditionalFormattingRule; + } + + private static ConditionalFormattingValueObjectTypeValues GetCFType(DocumentFormat.OpenXml.Spreadsheet.ConditionalFormatValueObjectValues value) + { + return CFValueToTypeMap[value]; + } + } +}