技术文章

HotXLS:在 Delphi 中实现条件格式与富文本

OOXML 中的一条条件格式规则,是顶着同一个名字的两件独立的事。条件(一个比较、一个公式、一段文本匹配)决定哪些单元格符合要求。外观(一条差分格式记录,ECMA-376 中称为 dxf)决定这些单元格长什么样。Excel 的对话框让你同时填好两者,掩盖了这道接缝。HotXLS 没有。在 Delphi 中创建一个 cellIs 规则却跳过样式,这条规则就是有效的、范围是正确的、公式恰好在正确的单元格上求值为 true,而且什么颜色都不变——因为规则的指令是"为真,但什么都不画"。条件与后果之间的这道缝隙是首先要理清的事,它也解释了大多数那种在"管理规则"里看起来正确却什么也高亮不了的规则

HotXLS 把条件格式原生写入 BIFF8 .xls 和 OOXML .xlsx 文件,富文本运行和池化的单元格样式模型也是如此。这三项功能比扁平的 API 表面共享着更多的内部连线,而输出偏离意图的地方,通常就在它们之间的接缝处

条件需要一个后果:dxf 样式

在 XLSX 工作表上,比较规则来自 AddConditionalFormat,它接收一个范围、一个取自 TXLSXCfOperator 的运算符以及一个公式或字面量,然后返回新规则在工作表的 ConditionalFormats 集合中的索引。该索引处的规则对象暴露一个 Style 属性,高亮就住在这里。在它上面设置填充,符合条件的单元格就取该填充。让它保持原样,你就建出了上文描述的那条隐形规则

HotXLS cellIs 规则从 Delphi 分两半构建的示意图:AddConditionalFormat 为条件返回规则索引,ConditionalFormats[Idx].Style.SetFillBgColor 提供 dxf 后果;从未设置样式的规则校验照样通过却什么也不画
条件决定哪些单元格入选,dxf 样式决定它们长什么样;省略样式等于造出一条看不见的规则
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Idx: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('kpi.xlsx');
    Sheet := Book.Sheets[0];

    // 负差异:浅红色填充
    Idx := Sheet.AddConditionalFormat('D2:D200', xlsxCfOpLessThan, '0');
    Sheet.ConditionalFormats[Idx].Style.SetFillBgColor($FFFFC7CE);

    // 重复的订单 ID 也以相同方式标记
    Idx := Sheet.AddCondFormatDuplicateValues('A2:A200');
    Sheet.ConditionalFormats[Idx].Style.SetFillBgColor($FFFFEB9C);

    // 自定义公式规则:高亮实际值未达到目标 90% 的行
    Idx := Sheet.AddCondFormatExpression('B2:B200', '$C2<$B2*0.9');
    Sheet.ConditionalFormats[Idx].Style.SetFillBgColor($FFFFC7CE);

    Book.SaveAs('kpi-flagged.xlsx');
  finally
    Book.Free;
  end;
end;

这里的颜色是 32 位 ARGB 值,所以 $FFFFC7CE 就是你从对话框里认识的那种 Excel"浅红",在 RGB 前面是一个完全不透明的 alpha 字节。每一种按逐单元格条件触发的规则类型都遵循同样的"先创建后设样式"模式。文本匹配器(AddCondFormatContainsTextAddCondFormatBeginsWithAddCondFormatEndsWith)返回一个你随后设样式的索引,AddCondFormatTop10AddCondFormatAboveAverage 以及空白和错误检测器也是如此。把这个模式学会一次,整个文本与比较家族的行为都一样

数据条、色阶和图标集自行绘制

视觉类规则的工作方式正好相反。它们把外观携带在规则定义内部,完全无视 Style 属性。给一条数据条规则赋一个填充什么也不会发生,这看起来像 bug,直到分类法在脑子里理顺:AddCondFormatDataBar 把条的颜色作为直接参数,两点和三点的色阶同样以直接方式接收端点颜色,而 AddCondFormatIconSeticsTrafficLights3 等 26 种图标集类型中选一种。这里没有会被遗忘的独立样式记录,因为根本没有独立样式记录

这些调用上值得多想一想的参数是值锚点,类型为 TXLSCfValueKind。一条数据条或色阶的端点可以落在范围最小值或最大值处、一个字面数字处、一个百分数或百分位处,或一个公式的结果处。默认的最小值和最大值在整洁的演示数据上表现良好,然后在带有离群值的真实数据上背叛你:一个失控的值把色阶拉长,把其他每一条都压成一段残桩。当仪表板要在多个期间之间被阅读时,把端点锚定到固定数字或百分位上,这样三月里的半条就代表和四月里半条相同的量。一条自动缩放的条只与自己可比

XLS 写入器只覆盖四种规则,仅此而已

遗留的 BIFF8 侧并不是 XLSX 侧的一个更小的镜像,它是一个有意为之的子集。XLS 门面只能创建恰好四种条件规则形状——数据条、双色阶、三色阶和图标集——以 CF12 记录写入流。它没有 cellIs、表达式或文本规则的创建 API。那些种类的规则如果已存在于你打开的文件中,会被读取、保留并无更改地写回,所以打开并重新保存客户的 .xls 永远不会损坏它带入的格式。你做不到的是从零开始把阈值高亮生成进一个 .xls。那里的选择要么是用代码算出的普通单元格填充来模拟,要么把交付物做成 .xlsx,在那里完整的规则家族都在桌面上

这是一项要在数据层存在之前而不是之后敲定的约束,因为它改变了任何仪表板形态的东西的文件格式决策。一支为了兼容性选择 .xls、然后又为 KPI 报告规定 cellIs 阈值的团队,选了两件彼此不搭的东西,而更便宜的察觉时机是在格式决策时,而不是构建开始三周之后

规则堆叠、优先级与重叠范围

真实的仪表板很少一个范围只跑一条规则。一个差异列可能带一条表示量级的数据条、一条表示硬阈值的 cellIs 规则,以及在这两者之上的一条行级表达式规则用于升级。每条 TXLSXConditionalFormat 暴露一个 Priority 值,而 Excel 按优先级顺序解决竞争规则。当两条规则想画同一个单元格时,胜者由你设的那个数字决定,而不是由评审者碰巧在"管理规则"对话框里滚到的顺序决定

把优先级当作绘图程序对待 z-order 那样来处理。凡是两条规则能触及相同单元格的地方,都要有意识地赋值,并在值之间留出空隙,好让后来的规则能插入而无需给其余重新编号。在规则不会冲突的地方,比如一条限于 E 列的数据条和一条限于 G 列的文本规则,创建顺序就够了,优先级不值得费心。把那份注意力花在范围边界上吧,因为这里昂贵的 bug 几乎从来不是优先级倒置。它们是像 B2:B200 这样的范围用在一个已经长到 350 行的报告上,未覆盖的尾部渲染为普通单元格,看起来和健康数据一模一样。把每条规则范围都从驱动工作簿其他地方的图表系列和验证范围的同一个最终行计数值派生出来,尾部就不会再掉队

有一个校验习惯值得坚持。生成后,在 Excel 中打开文件,选中带格式的范围,每次模板改动都把"管理规则"走一遍。条件格式是为数不多唯一的权威渲染器就是消费文件的应用程序的领域之一,所以对 XML 的单元测试只能证明规则被写进去了,不能证明 Excel 按你期望的方式绘制它。一分钟的肉眼复核就能弥合这道缝隙

富文本:一个单元格里多种格式

XLSX 模型里的一个富文本单元格持有一列运行(run),每段运行是一段文本加上它自己的字体属性。你在旁边构建这个列表为一个 TXLSXRichText 对象,向它添加运行,然后把整件东西附加到一个单元格上。所有权规则是会咬人的部分。赋值给 Cell.RichText 会把该对象的所有权交给单元格,单元格在自身析构时释放它。你自己再释放一次就是双重释放,这种问题在引发它的那次运行中保持沉默,而在很久之后某个不相关的地方以崩溃形式浮现

Delphi 中 HotXLS 富文本运行示意图:把 TXLSXRichText 对象赋给 Cell.RichText 会把所有权移交单元格,二次 Free 会在很久之后破坏堆;运行颜色只有在清除 ColorIsAuto 后才生效
文本串列表的所有权在赋值时移交单元格;颜色赋值只有在 ColorIsAuto 清除后才会生效
var
  Rich: TXLSXRichText;
  Run: TXLSXRichTextRun;
begin
  Rich := TXLSXRichText.Create;
  Rich.AddRunText('Status: ');
  Run := Rich.AddRunText('OVERDUE');
  Run.Bold := True;
  Run.Color := $FFC00000;
  Run.ColorIsAuto := False;
  Run := Rich.AddRunText(' (escalated to regional manager)');
  Run.Italic := True;
  Sheet.Cells[2, 7].RichText := Rich;   // 所有权转移到单元格:不要 Free
end;

显式的 ColorIsAuto := False 不是可有可无的装饰。一段运行携带一个自动颜色标志,只有在该标志被清除之后颜色赋值才会被采纳。设了 Color 却忘了 ColorIsAuto,运行出来是粗体但顽固地保持黑色,没有任何错误指向原因。运行还支持删除线、下划线变体,以及用于上标和下标的垂直对齐,而 PlainText 在你需要导出或比对文本内容时把整列展平回单个字符串

单元格级的富文本仅限 XLSX。XLS 门面没有写它的公开 API,不过在那里通过 TextRuns,运行在批注和文本框上是可用的,而从已有 .xls 读出的富字符串在一次往返中完好无损。这里的牵引力和条件格式一样:任何在单元格内混合格式的东西都属于 XLSX 写入器

样式池与那个会出厂的 off-by-one

XLSX 模型中普通的单元格样式通过工作簿上的池化集合进行。Fonts.AddFills.AddSolidBorders.Add 各自注册一个定义并返回它在池中的索引。这些索引是 0 基的。消费它们的单元格侧属性(比如 FontIndex)把 0 保留给"默认",所以你赋给单元格的值是池索引加一:

HotXLS XLSX 样式池差一错误示意图:Fonts.Add 返回从 0 起的池索引,而单元格 FontIndex 从 1 起且 0 保留给默认值;漏掉加一会让所有表头悄然失去样式
池索引从零开始,单元格索引把零保留给默认项,因此单元格一侧总是加一
HeaderFont := Book.Fonts.Add('Calibri', 11, True, False);  // pool index, 0-based
for Col := 1 to 6 do
  Sheet.Cells[1, Col].FontIndex := HeaderFont + 1;          // cell index, 1-based

漏掉那个 + 1,每个表头都会回退到默认字体。没有异常也没有警告,只有一个看起来没人给它设过样式的工作簿。二阶错误藏在循环里:每行调用一次 Fonts.Add。相同的字体定义会被去重,所以文件不会损坏,但工作是浪费的,而尤其对齐池在每次调用时都返回一个新对象而非折叠重复项。在循环之前一次性建好那一小撮样式并复用它们的索引。在十万行的报告上,这一处改动正是HotXLS 大型工作簿性能调优涵盖的杠杆之一。当你只需要一种现成的语义外观时,两套门面都在范围上暴露 ApplyBuiltinStyle,它映射到 Excel 内置的 Good、Bad、Neutral 和强调色样式,而你完全不用碰那些池

条件格式、富文本和池化样式是报告的最后一英里,在数据模型和布局定稿之后才应用,而那些更早的阶段是基于模板的 HotXLS 报告生成的主题。完整的规则、运行和样式参考见 HotXLS Delphi Component 产品页