技术文章

HotXLS 在 Delphi 中求值 Excel 条件格式

HotXLS 是面向 Delphi 和 C++Builder 的原生电子表格组件,从 2.209.0 版本起,它能回答一个 Excel 通常只留给自己的问题:对这一个具体的单元格来说,哪些条件格式规则会触发,最终解析出的填充、字体、数据条或图标又是什么。这个答案,正是你的输出是一份 HTML 报表、一份 PDF,或者一个你自己绘制的网格控件时立刻需要用到的东西

这和创建规则是两个不同的问题。之前两篇笔记讲的是编写规则那一侧:条件格式与富文本样式讲的是把规则和差异化格式附加到一个范围上,锚定条件格式的分区讲的是插入或删除行列时规则范围会发生什么。这两篇都是结构性的。而这一篇讲的是语义:给定一份已经带有规则的工作簿,如何算出高亮结果

为什么文件格式不会告诉你哪些单元格会亮起来

简短的答案是,ECMA-376 和 ISO 29500-1 定义的是存储方式,不是求值算法。一个 conditionalFormatting 元素(§18.3.1.18)带有一个 sqref 和一份 cfRule 子元素列表(§18.3.1.10),每条规则带有一个 type、一个可选的 operator、一个 priority、一个 stopIfTrue 标志、一到两个 formula 子元素,对于视觉类家族还有一组 cfvo 阈值。这些字段都忠实地描述了用户配置了什么,但没有一个是算法本身。对一半的规则类型来说,这个空白无关紧要:cellIs 配上 operator="greaterThan" 就是"大于",containsText 就是"存在这个子串"。空白出现在聚合类家族上。一条 rank="10"percent="1"top10 规则,作用在 27 个有数据的数值单元格上,到底要高亮多少个?2.7 不是一个可以直接用的数字。四舍五入、向下取整还是向上取整——规范对此只字未提,选错了就意味着你的 PDF 和客户桌面上正开着的那份工作簿对不上

单元格级规则,以及 TCondFormatRule.Evaluate 止步于何处

HotXLS 先啃了容易的那一半。lxCondFormat.pas 里的 TCondFormatRule.Evaluate,在 2.199.0 版本加入,能回答"某条规则对某一个单元格是否触发",而不需要知道范围里其他任何单元格的信息。它处理 cellIs 背后的八种 BIFF 比较运算符(between、notBetween、equal、notEqual、greater、less、greaterEqual、lessEqual)、在单元格位置求值以便相对引用正确重定位基准的自由格式 expression 规则、四种文本谓词,以及空白和错误谓词。阈值来自 FFormula1FFormula2,通过 TXLSCalculator.GetRangeValue 在该单元格位置解析得到,上下界颠倒时会被交换而不是被拒绝

var
  I: Integer;
  Rule: TCondFormatRule;
  Value: Variant;
begin
  Value := Sheet.Cells[Row, Col].Value;
  for I := 0 to CondFormat.RuleCount - 1 do
  begin
    Rule := CondFormat.Rule(I);
    // Single-cell verdict only. Aggregate and visual kinds answer False.
    if Rule.Evaluate(Calculator, SheetIndex, Row, Col, Value) then
      ApplyHighlight(Row, Col, Rule.Style);
  end;
end;

这个方法诚实的地方在于它拒绝猜测的那部分。top10aboveAveragebelowAverageduplicateValuesuniqueValues 都返回 False,不是因为它们难实现,而是因为单靠一个单元格根本无法判定——它们每一个都需要一个覆盖整个范围的统计量。四种视觉类家族,dataBarcolorScale2colorScale3iconSet,返回 False 是出于另一个原因:它们从来就不会产出一个布尔值,产出的是一份渲染载荷,布尔返回类型对它们来说本来就是错的形状

工作表级求值器如何避免重复扫描整张表

做法是把每一个共享的量都在构造时算一次,之后再也不算。lxHandleX.pas 里的 TXLSXConditionalFormatEvaluator 是针对一张工作表的不可变快照,通过 TXLSXWorksheet.CreateConditionalFormatEvaluator 构建,它整体的设计都是在防御那种"每画一个单元格就触发一次全范围扫描"的朴素实现

构造函数里发生了四件事。每一个不同的多区域 sqref 都恰好被解析一次,解析成一个 TXlsxCfRangeSnapshot,所以十条共享同一个范围的规则共享同一次解析和同一趟统计。这趟统计在一次遍历里流式计算出有数据单元格的均值、总体标准差、最小值和最大值,只有当确实存在需要顺序统计量的 Top/Bottom 或百分位规则时,才会保留一份有序数值数组。重复值和唯一值的键构建过程是 Unicode 安全的,而且只批量排序一次,不是每次查找都排一遍。然后行轴会在每个区域边界处切成若干条带,这样 EvaluateCell 只需对某条带做一次二分查找,只访问那些范围有可能覆盖到该行的规则

第四件事在规模扩大时最要紧。像 =A1>AVERAGE($A$1:$A$100) 这样的相对规则公式,在范围内每个单元格上的含义都不同,直观的实现方式是给每个单元格编译一棵全新的语法树。TXlsxCfRulePlan 只编译一次,然后通过可逆的坐标偏移量重新求值同一棵树,这样既保留了 Excel 的锚定行为,又不必给每个单元格分配一棵语法树。规则随后按 priority 分层,命中一条设置了 StopIfTrue 的规则就会跳出循环,和 Excel 的短路行为完全一致

var
  Evaluator: TXLSXConditionalFormatEvaluator;
  Res: TXLSXCfCellResult;
begin
  Evaluator := Sheet.CreateConditionalFormatEvaluator;
  try
    if Evaluator.EvaluateCell(Row, Col, Res) then
    begin
      if Res.HasFillColor then
        Canvas.Brush.Color := TColor(Res.FillColor);
      if Res.HasIcon then
        // IconIndex is zero-based inside Res.IconSetType
        DrawIcon(Res.IconSetType, Res.IconIndex, Res.IconCount);
      if Res.HasDataBar then
        // DataBarAxis and DataBarEnd are normalised to 0..1
        DrawBar(Res.DataBarAxis, Res.DataBarEnd, Res.DataBarColor);
      if not Res.ShowCellValue then
        Exit;  // showValue="0" on the rule hides the number
    end;
  finally
    Evaluator.Free;
  end;
end;

Excel 到底怎么对 Top 10 百分比规则做取整

它向下取整,最小值为 1,而且会把处在临界值上的并列项也一并算进去。这一点在 ISO 29500-1 里哪里都没写——是通过手工构造工作簿去试探 Excel 16、再回读应用程序实际高亮了哪些单元格锁定下来的。HotXLS 的实现完全照此执行:排名数量是 Floor(Count * Min(Rank, 100) / 100),算出来是零时提升为 1,并钳制在有数据的单元格总数以内,随后用 >= 去比较这个临界值,所以每一个恰好等于边界值的单元格都会被高亮,即便这样算出来的数量超过了原本要求的数量。27 个数值配一条 10% 规则会高亮两个单元格,再加上任何与第二名并列的其他单元格

高于平均值规则藏着第二处歧义:aboveAveragestdDev="1" 选取的是高于均值一个标准差的单元格,但样本标准差和总体标准差因贝塞尔校正而不同,在小范围上这个差异是肉眼可见的,而条件格式恰恰经常用在小范围上。Excel 16 用的是总体标准差,HotXLS 与之保持一致,equalAverage 标志只有在没有涉及标准差区间时,才会把严格比较变成含等号的比较。重复值和唯一值规则靠的则是键的等同性判断。如果一个单元格存的是数字 100,另一个存的是文本"100",Excel 会把它们当作同一个重复键,所以 HotXLS 会把数字型文本归一化进数值键空间,而不是直接比较原始字符串。空白单元格则是相反的情形:一个真正的空单元格会参与范围计数,但它本身不会被着色,所以一列里的所有空单元格不会全部互相当作重复项而被点亮

色阶与图标集:插值与边界规则

视觉类家族解析出来的是可直接渲染的数字,而不是布尔值,它们的边界行为也是靠同样的方式锁定下来的。对于带显式数值阈值的色阶,HotXLS 把位置比例钳制到闭区间 0 到 1,然后按通道插值,用截断而不是四舍五入——低于最小停止点的值直接拿最小颜色,而不是外推出来的颜色;三段式色阶通过和中间停止点比较来选定所在区间;两端阈值相同的退化色阶会直接坍缩为顶端颜色,而不是除以零。图标集需要的是相反方向的细致处理,因为除了第一个之外的每一个 cfvo 都带有自己的比较严格性:HotXLS 逐个阈值读取 ThresholdEqualsInclude,据此应用 >=>,从下往上走,让满足条件的最高阈值决定图标索引。反转后的集合翻转的是解析出来的索引,而不是阈值本身;逐图标覆盖可以从另一个家族里取一个图形;任何无效阈值都会直接中止该规则,而不是给出一个看着还算合理、实际错误的图标

用同一份结果同时驱动网格、HTML 导出和 PDF

因为 EvaluateCell 返回的是一个完全解析好的 TXLSXCfCellResult——差异化的填充和字体颜色(已应用主题色调)、粗体、斜体、下划线、数字格式 id、正负方向的数据条延伸长度、坐标轴位置、图标家族和索引——每个消费方读的都是同一份记录,没有谁需要理解规则的内部实现。HotXLS 在 HTML 导出、PDF 导出和交互式查看器里都用的是这同一条路径,这是让三个渲染器不至于渐行渐远的唯一实际做法。2.210.0 版本把它接入了 TXLSWorkbookViewer,后者为每张活动工作表缓存一个已准备好的求值器,在滚动、选择和重绘期间复用它,只在工作簿或工作表切换时才释放——如果每次 Paint 都重建一次快照,就会把整套"构造时算好"的设计完全废掉。这个缓存也是为什么会有 TXLSWorkbookViewer.RefreshConditionalFormats 这个方法的原因:快照是不可变的,所以如果你原地改动了挂接的工作簿,聚合统计量和已解析的阈值在调用它之前都是过期的

// Editing behind a live viewer: the cached snapshot must be invalidated.
Sheet := Viewer.XlsxWorkbook.Sheets[1];
Sheet.Cells[5, 2].Value := 4200;         // changes mean, min, max, ranking
Viewer.RefreshConditionalFormats;        // drop evaluator, repaint

这个求值器不会替你做的事

有三条边界值得明说。经典的单元格级 TCondFormatRule.Evaluate 和工作表级的 TXLSXConditionalFormatEvaluator 是两个能力不同的接口,单元格级的那个刻意对聚合类和视觉类家族一律拒答,而不是去近似它们——如果你需要 Top/Bottom 或色阶,就得构建求值器。相对日期区间取决于求值那一刻的机器时钟,所以一条 timePeriod 规则今天生成的 PDF 和下周生成的 PDF 渲染结果会不一样,这是正确的行为,但如果你的归档要求逐字节稳定,这依然会变成一张支持工单。第三条是语法层面而不是实现层面的:条件格式的公式语法禁止结构化表引用,所以规则没法像工作表公式那样按名字引用一个表格列,这是格式本身的限制,不是实现的限制

如果你正在构建报表输出、导出流水线,或者一个必须和 Excel 逐单元格对齐的自定义网格控件,同一份解析结果同样驱动着本博客另一篇文章描述的自定义 VCL 电子表格网格。完整的 API 文档、规则模型和 HotXLS Delphi 电子表格组件的试用下载都可以在产品页获取