fix(xlsx): emit Excel 2010+ x14 extension for databar CF

Databar conditional format previously emitted only the Excel 2007
<x:dataBar> element, which causes Excel to render negative values as
positive (rightward bars, no zero-axis, no red for negatives).

Now emit the paired x14 extension under the worksheet extLst:
 - x14:dataBar with axisPosition, fillColor, negativeFillColor, axisColor
 - x14:cfRule linked to the 2007 cfRule via {B025F937-...} extension GUID
 - mc:Ignorable="x14" on the worksheet root (via new helper
   EnsureWorksheetX14Ignorable, factored out of the sparkline path)

New user props:
 - negativeColor=<hex>   (default FF0000)
 - axisColor=<hex>       (default 000000)
 - axisPosition=automatic|middle|none

Also: omit cfvo val="" attribute when type is min/max (Excel rejects
the empty attribute).
This commit is contained in:
zmworm 2026-04-19 02:23:15 +08:00
parent 502976ef29
commit 318edca91f
2 changed files with 134 additions and 8 deletions

View file

@ -1076,16 +1076,19 @@ public partial class ExcelHandler
Priority = NextCfPriority(GetSheet(cfWorksheet))
};
var dataBar = new DataBar();
dataBar.Append(new ConditionalFormatValueObject
// R10-1: when cfvo type is min/max, omit `val` attribute (Excel rejects val="").
var dbMinCfvo = new ConditionalFormatValueObject
{
Type = minVal != null ? ConditionalFormatValueObjectValues.Number : ConditionalFormatValueObjectValues.Min,
Val = minVal
});
dataBar.Append(new ConditionalFormatValueObject
Type = minVal != null ? ConditionalFormatValueObjectValues.Number : ConditionalFormatValueObjectValues.Min
};
if (minVal != null) dbMinCfvo.Val = minVal;
dataBar.Append(dbMinCfvo);
var dbMaxCfvo = new ConditionalFormatValueObject
{
Type = maxVal != null ? ConditionalFormatValueObjectValues.Number : ConditionalFormatValueObjectValues.Max,
Val = maxVal
});
Type = maxVal != null ? ConditionalFormatValueObjectValues.Number : ConditionalFormatValueObjectValues.Max
};
if (maxVal != null) dbMaxCfvo.Val = maxVal;
dataBar.Append(dbMaxCfvo);
dataBar.Append(new DocumentFormat.OpenXml.Spreadsheet.Color { Rgb = normalizedColor });
cfRule.Append(dataBar);
// CF6 — dataBar `showValue=false` hides the cell's numeric
@ -1095,6 +1098,23 @@ public partial class ExcelHandler
dataBar.ShowValue = false;
ApplyStopIfTrue(cfRule, properties);
// R10-1: Also emit Excel 2010+ x14 extension so negative values
// render leftward in red with an axis. Without this block, Excel
// uses the 2007 dataBar which treats all values as positive
// (rightward blue bars, no axis, no red for negatives).
var dbGuid = "{" + Guid.NewGuid().ToString().ToUpperInvariant() + "}";
// Attach x14:id extension onto the 2007 cfRule so it's paired
// with the sibling x14:cfRule in the worksheet extLst.
var dbRuleExtList = new ConditionalFormattingRuleExtensionList();
var dbRuleExt = new ConditionalFormattingRuleExtension
{
Uri = "{B025F937-C7B1-47D3-B67F-A62EFF666E3E}"
};
dbRuleExt.AddNamespaceDeclaration("x14", "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main");
dbRuleExt.Append(new X14.Id(dbGuid));
dbRuleExtList.Append(dbRuleExt);
cfRule.Append(dbRuleExtList);
var cf = new ConditionalFormatting(cfRule)
{
SequenceOfReferences = new ListValue<StringValue>(
@ -1104,6 +1124,57 @@ public partial class ExcelHandler
var wsElement = GetSheet(cfWorksheet);
InsertConditionalFormatting(wsElement, cf);
// R10-1: Build the x14:dataBar counterpart under worksheet extLst.
var dbNegColor = ParseHelpers.NormalizeArgbColor(properties.GetValueOrDefault("negativeColor", "FF0000"));
var dbAxisColor = ParseHelpers.NormalizeArgbColor(properties.GetValueOrDefault("axisColor", "000000"));
var dbAxisPos = (properties.GetValueOrDefault("axisPosition") ?? "automatic").ToLowerInvariant();
var dbAxisPosVal = dbAxisPos switch
{
"middle" => X14.DataBarAxisPositionValues.Middle,
"none" => X14.DataBarAxisPositionValues.None,
_ => X14.DataBarAxisPositionValues.Automatic
};
var x14DataBar = new X14.DataBar
{
MinLength = 0U,
MaxLength = 100U,
AxisPosition = dbAxisPosVal
};
var x14MinCfvo = new X14.ConditionalFormattingValueObject
{
Type = minVal != null
? X14.ConditionalFormattingValueObjectTypeValues.Numeric
: X14.ConditionalFormattingValueObjectTypeValues.AutoMin
};
if (minVal != null) x14MinCfvo.Append(new DocumentFormat.OpenXml.Office.Excel.Formula(minVal));
x14DataBar.Append(x14MinCfvo);
var x14MaxCfvo = new X14.ConditionalFormattingValueObject
{
Type = maxVal != null
? X14.ConditionalFormattingValueObjectTypeValues.Numeric
: X14.ConditionalFormattingValueObjectTypeValues.AutoMax
};
if (maxVal != null) x14MaxCfvo.Append(new DocumentFormat.OpenXml.Office.Excel.Formula(maxVal));
x14DataBar.Append(x14MaxCfvo);
x14DataBar.Append(new X14.FillColor { Rgb = normalizedColor });
x14DataBar.Append(new X14.NegativeFillColor { Rgb = dbNegColor });
x14DataBar.Append(new X14.BarAxisColor { Rgb = dbAxisColor });
var x14CfRule = new X14.ConditionalFormattingRule
{
Type = ConditionalFormatValues.DataBar,
Id = dbGuid
};
x14CfRule.Append(x14DataBar);
var x14Cf = new X14.ConditionalFormatting();
x14Cf.AddNamespaceDeclaration("xm", "http://schemas.microsoft.com/office/excel/2006/main");
x14Cf.Append(x14CfRule);
x14Cf.Append(new DocumentFormat.OpenXml.Office.Excel.ReferenceSequence(sqref));
EnsureWorksheetX14ConditionalFormatting(wsElement, x14Cf);
SaveWorksheet(cfWorksheet);
var dbCfCount = wsElement.Elements<ConditionalFormatting>().Count();
return $"/{cfSheetName}/cf[{dbCfCount}]";

View file

@ -633,6 +633,61 @@ public partial class ExcelHandler
rule.StopIfTrue = true;
}
/// <summary>
/// Ensure the worksheet root declares `xmlns:x14` + `mc:Ignorable="x14"`.
/// Without both, Excel silently drops the x14 extension block where
/// sparklines, dataBar 2010+ extensions, and other Office2010 features
/// live. CONSISTENCY(x14-ignorable): same pattern the sparkline branch
/// uses inline.
/// </summary>
internal static void EnsureWorksheetX14Ignorable(Worksheet ws)
{
const string mcNs = "http://schemas.openxmlformats.org/markup-compatibility/2006";
const string x14Ns = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main";
if (ws.LookupNamespace("mc") == null)
ws.AddNamespaceDeclaration("mc", mcNs);
if (ws.LookupNamespace("x14") == null)
ws.AddNamespaceDeclaration("x14", x14Ns);
var ignorable = ws.MCAttributes?.Ignorable?.Value ?? "";
if (!ignorable.Split(' ').Contains("x14"))
{
ws.MCAttributes ??= new MarkupCompatibilityAttributes();
ws.MCAttributes.Ignorable = string.IsNullOrEmpty(ignorable) ? "x14" : $"{ignorable} x14";
}
}
/// <summary>
/// Append an x14:conditionalFormatting block to the worksheet's extLst under
/// ext URI `{78C0D931-6437-407d-A8EE-F0AAD7539E65}`. Creates the extension
/// on first call, appends to the existing x14:conditionalFormattings
/// container on subsequent calls. Also ensures mc:Ignorable="x14" is set.
/// </summary>
internal static void EnsureWorksheetX14ConditionalFormatting(Worksheet ws, X14.ConditionalFormatting x14Cf)
{
const string cfExtUri = "{78C0D931-6437-407d-A8EE-F0AAD7539E65}";
const string x14Ns = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main";
EnsureWorksheetX14Ignorable(ws);
var extList = ws.GetFirstChild<WorksheetExtensionList>() ?? ws.AppendChild(new WorksheetExtensionList());
var ext = extList.Elements<WorksheetExtension>().FirstOrDefault(e => e.Uri == cfExtUri);
X14.ConditionalFormattings cfContainer;
if (ext != null)
{
cfContainer = ext.GetFirstChild<X14.ConditionalFormattings>()
?? ext.AppendChild(new X14.ConditionalFormattings());
}
else
{
ext = new WorksheetExtension { Uri = cfExtUri };
ext.AddNamespaceDeclaration("x14", x14Ns);
cfContainer = new X14.ConditionalFormattings();
ext.Append(cfContainer);
extList.Append(ext);
}
cfContainer.Append(x14Cf);
}
/// <summary>
/// Mark a worksheet as dirty. The actual save (with schema-order reorder) is
/// deferred to <see cref="FlushDirtyParts"/> which runs in Dispose().