技術文章

Delphi HotXLS 中的 Excel 條件式格式評估

一條 Top 10% 規則落在 27 個銷售數字組成的欄上,Excel 塗了三個儲存格。27 的百分之十是 2.7,所以會四捨五入的渲染器得到三、無條件進位的得到三、無條件捨去的得到二。三種猜法有兩種在這一欄上恰好命中 Excel,而且兩種都錯了:Excel 把數量捨去成二,然後又把第三個儲存格一起塗上,因為它與第二名在 88 並列。只要改一個值,這些僥倖的猜測就會全數失靈

這就是在 Excel 之外畫條件式格式的全部難處。規則就在檔案裡;但「哪些儲存格亮起、亮成什麼顏色」的答案不在。如果你的輸出是 HTML 報表、PDF 或你自己畫的格線,就得自己算,而聚合規則家族正是算術藏身之處。編寫規則是另一個主題,條件式格式與豐富文字樣式一文已涵蓋;列移動時規則範圍會發生什麼事,則見拆分定錨的條件式格式。本篇談的是求值

conditionalFormatting 元素說了什麼、沒說什麼

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 就是子字串存在。麻煩從判決取決於整個範圍的規則開始:top10 搭配 percent="1" 沒說小數數量怎麼捨入,aboveAverage 搭配 stdDev="1" 沒說偏差是樣本還是母體,兩者也沒說恰好等於門檻的值算不算。選錯了,你的 PDF 就會與客戶在下一個視窗開著的活頁簿對不上

Excel 條件格式的儲存與求值對比圖:conditionalFormatting 元素儲存 sqref、規則類型、優先順序與門檻;Delphi 渲染器仍須計算哪些儲存格符合、top10 排名數如何捨入、門檻值是否納入
ISO 29500-1 存下每項規則設定,卻不含任何求值數學;彙總家族正是規範沉默之處

TCondFormatRule.Evaluate:單一儲存格自己能決定什麼

HotXLS 有兩個求值介面,差別在它們願意回答什麼。經典的 .xls 引擎在 lxCondFormat.pas 裡暴露 TCondFormatRule.Evaluate,它接受一個計算器、一個工作表索引、一個儲存格位置與該格的值,對單條規則回傳布林值。它涵蓋 cellIs 的八個運算子(介於、不介於、等於、不等於、大於、小於、大於等於、小於等於)、在儲存格處求值以讓相對參照正確重新定位的自由格式 expression 規則、四種文字判斷式,以及空白與錯誤判斷式。閾值公式在儲存格位置解析,上下界顛倒的 between 規則會被互換而不是被拒絕。文字比較不分大小寫,與 Excel 一致

// 經典引擎:一條規則、一個儲存格、一個布林值
function AnyRuleMatches(Calculator: TXLSCalculator; CondFormat: TCondFormat;
  SheetIndex, Row, Col: Integer; const Value: Variant): Boolean;
var
  I: Integer;
begin
  Result := False;
  for I := 0 to CondFormat.RuleCount - 1 do
    // top10、平均、重複值、時間週期與視覺類型
    // 在這裡回傳 False;它們需要整個範圍
    if CondFormat.Rule(I).Evaluate(Calculator, SheetIndex, Row, Col, Value) then
      Exit(True);
end;

這個方法對 top10、aboveAverage、belowAverage、duplicateValues 與 uniqueValues 回傳 False。看不見其他值,單一儲存格無從知道自己在不在前十百分比,硬答就等於瞎猜。相對日期規則(timePeriod)在這條路徑上也回傳 False。四個視覺家族——dataBar、colorScale2、colorScale3 與 iconSet——回傳 False 的理由不同:它們從不產出是或否,產出的是顏色、橫條長度或圖示索引,而布林值沒地方放這些。所以這個呼叫回傳 False,可能意味「這裡不裁決」,也可能意味「不符合」,拿來快速檢查比較規則沒問題,拿進要出貨的東西裡就不安全

HotXLS 在 Delphi 的兩個條件格式求值介面圖解:TCondFormatRule.Evaluate 以布林值回答單一儲存格的 cellIs、expression、文字與空白規則,拒絕彙總與視覺類型;TXLSXConditionalFormatEvaluator 則把每個家族解析成 TXLSXCfCellResult
單一儲存格介面拒絕替整個範圍瞎猜,工作表快照則代為解析彙總與視覺裝載

工作表評估器如何避免為每個儲存格重掃整張表?

它在被建立的那一刻,就把所有跨範圍的工作一次做完。lxHandleX.pas 裡的 TXLSXConditionalFormatEvaluator 是單一工作表條件格式的不可變快照,透過 TXLSXWorksheet.CreateConditionalFormatEvaluator 取得;呼叫端擁有它並負責釋放。樸素的替代做法是每個被上色的儲存格都觸發一次規則範圍掃描、重算平均或排名,那會把一份 10,000 格的匯出變成每條規則 10,000 次範圍掃描

建構時,評估器把每個相異的 sqref 只剖析一次,十條共用同一範圍的規則共用一次剖析、一趟有值儲存格的走訪。那趟走訪單次掃描就收集平均值、母體偏差、最小值與最大值,只有當 Top/Bottom 規則或百分位閾值確實需要順序統計量時,才保留一份排序過的數值副本。重複值與唯一值規則的值鍵也只建構、排序一次,之後每次查詢都是二分搜尋。規則公式只編譯一次,之後對每個儲存格帶著該格的偏移重新求值,所以 =A1>AVERAGE($A$1:$A$100) 的相對與絕對部分,行為與 Excel 裡一模一樣,卻不必每列重新剖析。列還會被分組,讓 EvaluateCell 只造訪範圍搆得到所請求那一列的規則

規則接著按 priority 順序套用,數字小的先上,並列時按檔案中的位置排序。每個格式屬性取自第一個設定它的相符規則,優先順序較低的規則只填補仍然空著的屬性:一條設定粗體的 priority-1 規則與一條設定紅色填色的 priority-2 規則會同時生效,儲存格出來就是紅底粗體。命中一條 StopIfTrue 已設定的規則,就結束該儲存格的走訪

procedure PaintConditional(Sheet: TXLSXWorksheet; Canvas: TCanvas;
  FirstRow, LastRow, FirstCol, LastCol: Integer);
var
  Evaluator: TXLSXConditionalFormatEvaluator;
  Res: TXLSXCfCellResult;
  Row, Col: Integer;
begin
  // 每個渲染趟次一個快照;列與欄都是 1 起算
  Evaluator := Sheet.CreateConditionalFormatEvaluator;
  try
    for Row := FirstRow to LastRow do
      for Col := FirstCol to LastCol do
      begin
        if not Evaluator.EvaluateCell(Row, Col, Res) then
          Continue;
        if Res.HasFillColor then
          Canvas.Brush.Color := TColor(Res.FillColor and $00FFFFFF);  // BGR,與 TColor 相同的版面
        if Res.HasDataBar then  // DrawBar 是你自己的常式
          DrawBar(Row, Col, Res.DataBarAxis, Res.DataBarEnd, Res.DataBarColor);
        if Res.HasIcon then     // DrawIcon 是你自己的常式
          DrawIcon(Row, Col, Res.IconSetType, Res.IconIndex);
        if Res.ShowCellValue and Res.HasNumberFormat and
           Sheet.Cells.HasCell(Row, Col) then  // DrawCellText 是你自己的常式
          DrawCellText(Row, Col,
            Evaluator.FormatValue(Sheet.Cells[Row, Col].Value, Res.NumberFormatId));
      end;
  finally
    Evaluator.Free;
  end;
end;

快照刻意是每趟建構一次。若在內層迴圈裡建立它,每個儲存格都會重新編譯、重新掃描一遍,那正是這個設計存在要避免的成本。ShowCellValue 在資料橫條規則帶 showValue="0" 或圖示集設定只顯示圖示時回傳 False,渲染器於是只畫橫條或圖形,把數字留下不畫

一條 Top 10% 規則到底會高亮幾個儲存格?

數量是捨去取整,從不低於一,而每個與門檻並列的值一律納入。規範沒有寫這件事;這是 Excel 的實際行為,把上圖那 27 個值打進一欄、套用前 10%、數一數被塗色的儲存格,一分鐘就能親自確認。TXLSXConditionalFormatEvaluator 把排名數量算成 Floor(Count * Min(Rank, 100) / 100),落零時提升為 1,鉗制到有值數值儲存格的數量,從排序後的數值讀出門檻,然後用 >= 比較每個儲存格。對 27 值的那一欄,結果是數量二、門檻 88,三個儲存格被塗色,因為其中兩個存的是 88。Bottom-N 規則用 <= 鏡像同一套邏輯

HotXLS 圖解:27 個有值儲存格上的前 10% 條件格式規則——Excel 把排名數向下取整為 2、以大於等於比較門檻值,第三個與門檻同分的儲存格和前兩名一起亮起
Excel 把排名數向下取整、下限為一,並保留每個與門檻並列的儲存格;HotXLS 套用同樣的數量與同樣的並列規則

高於平均規則帶著第二個歧義。搭配 stdDev="1" 時,規則選取高於平均值至少一個標準差的儲存格,而樣本偏差與母體偏差差在一個貝索校正,在人們實際使用這條規則的短範圍上影響最大:五個值就能差出約 12%。HotXLS 用母體偏差,也就是 STDEV.P 回傳的那個數,所以要檢查任何渲染器,最快的辦法是在範圍旁邊放一個 =AVERAGE(r)+STDEV.P(r),看哪些儲存格過得了它。對偏差區間的比較是含邊界的;單純的高於/低於比較則是嚴格的,除非規則帶 equalAverage="1"

重複值與唯一值規則取決於什麼算「同一個值」。數字 100 與文字「100」共用一個鍵,因為數值文字會被正規化進數值鍵空間;文字鍵比較不分大小寫;TRUE 與 1 保持相異,不同種類的錯誤值也一樣。含 * 或 ? 的文字按 COUNTIF 式的萬用字元模式比對,所以存著 Q* 的儲存格會算進每個以 Q 開頭的值。空白儲存格是相反的陷阱:範圍內真正的空儲存格永遠不會被當成重複值塗色,於是沒填完的一欄不會從頭亮到尾,而存了空字串的儲存格則會被算進空白那一側

色階、資料橫條與圖示集回傳的是數字

視覺家族解析成可直接渲染的數值,而它們的邊界正是手寫渲染器漂移的地方。對色階而言,EvaluateCell 把位置鉗制到 0 到 1 的區間,逐 RGB 色版以截斷方式插值,所以低於最小端點的值拿到的是最小顏色,而不是外推色。三段色階對中點閾值以上的值用中點到最大的那一對、其餘用最小到中點的那一對。首尾閾值算出同一個數的色階會回傳最後一個顏色,而不是除以零。百分位閾值在相鄰的排序值之間線性插值,即 PERCENTILE.INC 的定義

資料橫條正規化到儲存格寬度。範圍全為正數時,DataBarAxis 是 0,DataBarEnd 落在規則的最短與最長橫條長度之間,預設是 10% 與 90%,所以最小的值仍有一小截橫條,最大的值也永遠不會完全填滿儲存格。範圍跨零時,軸落在它的比例位置,DataBarEnd 標出橫條停下的地方,負值落在軸的另一側。方向、漸層、邊框與負值橫條顏色填在同一筆記錄上,主題色與色調都已解析完成

圖示集需要對每個閾值的嚴格性小心,因為第一個之後的每個 cfvo 都帶有自己的 gte 旗標。評估器向上走訪閾值,按 ThresholdEqualsInclude 的指示逐閾值套用 >= 或 >,值能通過的最高閾值勝出。反轉的集合會翻轉解析出的索引;逐圖示覆寫可以透過 IconSetType 從另一個家族抽換圖形;無法解析的閾值會讓該儲存格的規則中止,儲存格於是拿不到任何圖示,而不是拿到一個看似合理、實則錯誤的圖示

一份結果,餵格線、HTML 匯出與 PDF

TXLSXCfCellResult 帶著渲染器需要的一切:套用主題色調後的填色與字色、粗體、斜體、底線、刪除線、數字格式 id、資料橫條幾何與顏色、圖示家族與索引。HotXLS 把同一筆記錄餵給它的 HTML 匯出、PDF 匯出與 TXLSWorkbookViewer,三種輸出因此逐儲存格一致。檢視器第一次繪製時為作用中的工作表建立一個評估器,在捲動、選取與重繪之間重複使用,切換工作表或指派別份活頁簿時才丟棄

// 在即時檢視器背後編輯:快取的快照看不到這筆變更
Sheet := Viewer.XlsxWorkbook.Sheets[1];   // 1 起算的工作表索引
Sheet.Cells[5, 2].Value := 4200;          // 移動了平均值、最大值與排名
Viewer.RefreshConditionalFormats;         // 丟棄快照並重繪

快照不可變,所以就地編輯會讓平均、排名與色階端點保持過期,直到 RefreshConditionalFormats 執行。對你自己的程式碼也是同樣道理:跨編輯保留的評估器必須釋放重建,因為 CreateConditionalFormatEvaluator 捕捉的是它被呼叫那一刻的值

相對日期規則值得事先做個決定。工作表評估器拿 timePeriod 規則與機器的當前日期比較,所以「本週」規則在今天產出的 PDF 裡是一種渲染,同一份活頁簿下週二再匯出又是另一種。這是正確的行為,但如果你的封存被期望可重現,它照樣是一張支援單。要封存的報表,把 timePeriod 規則換成一條與儲存格裡所存報表日期比較的 expression 規則,例如七天窗口用 =AND(A2>=$Z$1-6,A2<=$Z$1);輸出就只取決於活頁簿本身。Excel 自己也不允許條件格式公式裡出現結構化表格參照,所以把規則指向該欄的儲存格範圍即可

如果目標是螢幕而不是檔案,同一筆結果記錄也驅動著本部落格其他文章描述的自訂 VCL 試算表格線。規則模型、評估器 API 參考與試用建置都在HotXLS Delphi 試算表元件頁面上