技術文章

HotXLS BIFF8 56 色調色盤與 OKLab 色彩對映

若以 RGB 通道值的單純歐幾里得距離來量,深藍色 $000033 離黑色 51 個單位,離經典的海軍藍項目 $000080 則有 77 個單位。因此,用最近 RGB 比對寫出傳統 .xls 檔的轉換器,會把深藍色的標題列塗成黑色,而所有只看調色盤索引的讀取器顯示的也就是黑色。格式沒給寫入端任何餘地:儲存格格式存的是調色盤索引,而調色盤只有 56 個項目。要把標題列弄對需要兩件事:一個能跟上人眼所見的距離量度,以及當活頁簿使用的相異顏色多過調色盤容量時,改寫調色盤本身的辦法

56 色上限從哪裡來

BIFF8 的字型、填滿或框線記錄從來不帶 RGB 值。它帶的是顏色索引,而活頁簿全域的 Palette 記錄($0092,[MS-XLS] §2.4.188)恰好為索引 8 到 63 提供 56 個不透明的 RGB 項目。索引 0 到 7 是八種基本色的固定副本。63 以上的值根本不是顏色,而是 token:$40 是系統前景,$41 是系統背景,$4D 到 $4F 分別是圖表文字、圖表背景與中性圖表色,$7FFF 代表自動。Excel 2007 新增了 XFExt 記錄($087D,§2.4.355),能為儲存格格式附上完整的 RGB 或佈景主題色,但 Excel 97–2003 以及任何只讀 XF 記錄的剖析器,看到的仍然只有索引

HotXLS 透過 1 到 56 的公開 ColorIndex 公開調色盤,也就是實體索引減 7。這裡同時有三套編號方案,ResolveIndexedColor 用 TXLSIndexedColorSpace 把它們區分開:xicsPublicColorIndex 對應 1..56 的 API 值,xicsBiffIcv 對應磁碟上的原始索引,xicsOoxmlIndexed 則是 64 與 65 代表系統前景與背景的那一套。原始 BIFF 索引還會對照你以 TXLSIndexedColorRole 傳入的角色來檢查,因為字型、填滿與圖表各自接受不同的 token 集合;$51 對字型合法,對填滿則無效

HotXLS 透過 TXLSIndexedColorSpace 區分三套索引顏色方案:原始 BIFF icv 值,其中 0 到 7 固定為八種基本色、8 到 63 是 Palette 記錄 $0092 的 56 個調色盤槽位、63 以上是系統前景之類的 token;偏移負 7 的公開 ColorIndex 1 到 56;以及 64 與 65 代表系統前景與背景的 xicsOoxmlIndexed
同一個顏色索引在每套方案裡代表不同的數字,所以 HotXLS 把每個值都導過 ResolveIndexedColor,而不是讓原始 BIFF token 冒充公開的 ColorIndex

ResolveIndexedColor 的布林結果是呼叫端最常絆倒的地方。它只在取得具體的 ARGB 值時才回傳 True,而短的多載從不向作業系統詢問桌面顏色。因此系統或自動 token 會回傳 False,並把 Res.Kind 設為 xickSystem 或 xickAutomatic,而把 False 解讀成「沒有顏色」的程式碼,就悄悄丟掉了這個 token 的意義。唯一的例外是 $4F,中性圖表色,它不需要幫忙就能解析成黑色。要為其他 token 取得真正的 RGB 值,請把一個 TXLSTryResolveSystemColor 方法傳給長的多載,並針對 UI、匯出或無頭服務套用你自己的政策:

type
  TExportColors = class
    function ResolveSystemColor(AColor: TXLSSystemColorRole;
      ARole: TXLSIndexedColorRole; out AArgb: LongWord): Boolean;
  end;

function TExportColors.ResolveSystemColor(AColor: TXLSSystemColorRole;
  ARole: TXLSIndexedColorRole; out AArgb: LongWord): Boolean;
begin
  // 無頭匯出:用固定顏色,而不是目前的桌面佈景主題
  Result := True;
  case AColor of
    xscrWindowText, xscrChartText, xscrToolTipText:
      AArgb := $FF000000;
    xscrWindowBackground, xscrChartBackground:
      AArgb := $FFFFFFFF;
  else
    Result := False;
  end;
end;

var
  Colors: TExportColors;   // 由你的匯出程式碼建立一次
  Res: TXLSIndexedColorResolution;
begin
  // $41 是系統背景 token,對填滿有效
  if Workbook.ResolveIndexedColor($41, xicsBiffIcv, xicrFillBackground,
    Colors.ResolveSystemColor, Res) then
    PaintBackground(Res.ARGB)        // 你自己的繪圖常式
  else if Res.Kind = xickInvalid then
    RejectToken(Res.RawIndex);       // 你自己的錯誤處理
end;

為什麼 RGB 距離會挑錯調色盤項目?

sRGB 的通道值是經過 gamma 編碼的,所以數字上相等的步距,在人眼看來並不相等,而誤差在企業範本最愛的深色飽和色調上最大。在 RGB 裡,$000033 離黑色 51、離海軍藍 77,所以黑色勝出。一旦把每個通道線性化並把顏色投影到 OKLab,到黑色的距離平方約為 0.0312,到海軍藍約為 0.0235,海軍藍勝出。比對結果是實體槽位 18 的 ColorIndex 11,Classic 與 XLSX 兩套引擎給出相同答案

HotXLS 如何把深藍色 $000033 對應到調色盤:在 gamma 編碼 RGB 中的歐幾里得距離量得離黑色 51、離海軍藍 77,會把標題塗黑;而 0.0312 與 0.0235 的 OKLab 距離平方讓 NearestIndexedColor 選中海軍藍,即實體槽位 18 的 ColorIndex 11
gamma 編碼的通道值讓 RGB 距離成為人眼所見的糟糕代理指標,所以 HotXLS 先轉換一次到 OKLab,再讓單純的歐幾里得距離平方比較來驅動調色盤掃描

預設調色盤還多了一個皺褶。海軍藍出現兩次,一次在 ColorIndex 11,一次在 25,所以 $000033 到兩者的距離完全相同。掃描順序不同,或者用小於等於來比較的比對器,這次挑 25、另一個建置挑 11,兩個產生出來的檔案就沒辦法乾淨地 diff 了。HotXLS 從槽位 8 往上掃描,用嚴格的小於比較,所以平手時一律歸較低的索引

選 OKLab 是刻意的取捨。CIEDE2000 對某些色相配對的排序會更準確,但它的分段修正讓它變慢,也更難在迭代迴圈裡推理。OKLab 每個顏色只需要一次線性化、兩次小型矩陣乘法與三次立方根,之後單純的距離平方就足以代表感知差異。對一個在每次顏色指定時都會執行、還會跑在分群階段裡的 56 項調色盤掃描來說,更便宜、更平滑的量度才是正確選擇

NearestIndexedColor 回傳什麼?

NearestIndexedColor 接受一個 ARGB 值並填好一個 TXLSNearestIndexedColorMatch 記錄,不會改動活頁簿。每個活頁簿都把 56 個調色盤顏色連同它們的 OKLab 座標以及一個調色盤世代計數器放在一起,所以一次查詢的成本是一次輸入轉換加一次固定掃描。重設調色盤會重建這份快取,更改單一項目只更新那一個槽位,而對一份與目前世代不同步的快取做查詢,會回傳 False 而不是亂猜

Alpha 遵循一條很窄的規則。Alpha 位元組為零視為不透明,這符合大多數 Delphi 程式碼傳遞純 RGB 值的方式,而 $FF 依定義就是不透明。介於兩者之間的值一律拒絕:函式回傳 False,ColorIndex 為 0、PaletteSlot 為 -1,因為調色盤項目沒有地方存放透明度

var
  Match: TXLSNearestIndexedColorMatch;
begin
  if Workbook.NearestIndexedColor($FF000033, Match) then
  begin
    // 預設調色盤:Match.ColorIndex = 11、Match.PaletteSlot = 18、
    // Match.ARGB = $FF000080
    if not Match.ExactMatch then
      LogApproximation(Match.InputARGB, Match.ARGB,   // 你自己的記錄器
        Match.DistanceSquared);
  end;
end;

同一個比對器也坐在 RGB 設定器的背後。在 Classic 活頁簿上指定 Interior.Color、Font.Color 或 Border.Color,會為 XFExt 記錄保留精確的顏色,並用這個掃描挑出存進 XF 記錄的索引,填滿與框線的寫入器也用同樣的方式把 RGB 與佈景主題色解析成索引。NearestIndexedColor 對某個顏色回報的是哪個槽位,只看索引的讀取器顯示的就是那個槽位

把全彩活頁簿塞進 56 個槽位

對少數幾個顏色來說,比對預設調色盤就夠了。一份帶著十幾種幾乎一模一樣藍色的品牌範本需要更多:調色盤本身必須改變。BuildBiffPalettePlan 會為全部 56 個槽位計算一份完整的提案,並讓活頁簿保持不動,所以你可以記錄它、拿它跟門檻值比較,或者直接丟掉

規劃器從 ScanIndexedColorUsage 開始。任何被字型、填滿、框線、條件式格式、圖案、註解、圖表字型或工作表格線以索引參照的槽位都會被鎖定,因為更改那個項目會一次替換掉該索引所有使用者的顏色。目標則是來自字型、填滿、漸層停駐點、框線、條件式格式樣式、資料橫條與色階的直接 RGB 與已解析的佈景主題色。每個目標以參照次數與定義次數中較大者加權,而條件式格式會把它範圍涵蓋的每個儲存格都算進去,所以塗滿整欄的填滿,權重會高過只在一則註解裡用到的顏色。接著放置依固定順序進行:

  • 鎖定的槽位無條件保留目前的顏色
  • 調色盤裡已經存在的目標保留它最低的相符槽位,該槽位隨即固定
  • 如果剩餘的相異目標塞得進空閒槽位,每個目標都拿到一個精確的槽位,依 ARGB 升冪指派
  • 否則會設定 Quantized,每個空閒槽位以「到最近既有中心的距離乘上權重」最大的那個目標作為種子,然後在 OKLab 中最多跑 16 輪加權 k-means,只移動空閒的中心,直到指派不再變動
HotXLS 處理全彩活頁簿的調色盤管線:ScanIndexedColorUsage 鎖定每個被字型、填滿、框線、條件式格式、圖案、註解或格線參照的槽位,BuildBiffPalettePlan 依 ARGB 升冪放置精確顏色,或在 OKLab 中跑最多 16 輪頻率加權 k-means,ApplyBiffPalettePlan 則在寫入前驗證世代與 FNV-1a 雜湊
規劃是唯讀且可重複的,計畫透過 WeightedError 與 MaxDistanceSquared 回報自己造成的損失,而過期的計畫會被拒絕、調色盤保持不動,因為計畫實質上是一次性的

溢位路徑值得冷靜地看清楚。這裡的 k-means 是有界的局部最佳化,空閒槽位最後裝的是一個經過 clamping 轉回 sRGB 的叢集中心,那可能是一個沒有任何儲存格原樣用過的顏色。規劃器真正保證的是可重複性:同一份活頁簿永遠產生同一份計畫。計畫也透過 WeightedError、MaxDistanceSquared、ExactTargetWeight 與 TotalTargetWeight 回報它折衷了多少,這足以讓批次作業在近似程度粗糙到不符合品牌規範時拒絕存檔。Targets 陣列則逐一列出每個輸入顏色對應到的槽位與它的距離平方

const
  MaxAcceptedError = 0.002;   // 你的門檻值,單位是 OKLab 距離平方
var
  Plan: TXLSBiffPalettePlan;
  I: Integer;
begin
  Plan := Workbook.BuildBiffPalettePlan;   // 唯讀
  if Plan.Quantized and (Plan.MaxDistanceSquared > MaxAcceptedError) then
    raise Exception.Create('Too many distinct colors for a BIFF8 palette');
  for I := 0 to High(Plan.Slots) do
    if Plan.Slots[I].Changed then
      LogSlot(Plan.Slots[I].ColorIndex, Plan.Slots[I].SourceARGB,
        Plan.Slots[I].TargetARGB);           // 你自己的記錄器
  if not Workbook.ApplyBiffPalettePlan(Plan) then
    raise Exception.Create('The palette changed after planning');
end;

ApplyBiffPalettePlan 什麼時候會拒絕計畫?

ApplyBiffPalettePlan 在寫入任何一個槽位之前會先檢查整份計畫,只要有任何地方與目前的活頁簿不一致,就回傳 False 並讓調色盤保持不動。計畫帶著 SourcePaletteGeneration 與 SourcePaletteHash,後者是對 56 個來源顏色計算的 64 位元 FNV-1a 雜湊。驗證還會重新檢查每個公開與實體索引、每個來源顏色、沒有任何鎖定槽位被標為已變更、鎖定與變更的數量加總正確,以及每個目標都是不透明的

規劃與套用之間任何真正的調色盤變更都會讓計畫過期,包括先前已成功套用過同一份計畫。實務上計畫是一次性的:建立、檢查、套用,需要另一份就再建一次。沒有任何槽位變更的有效計畫會成功,但不推進世代。真正的變更會推進一次世代,並更新一次比對器。在 Classic 引擎上新顏色直接寫進調色盤;在 XLSX 引擎上它們變成自訂索引顏色,由 HasCustomIndexedColor 與 CustomIndexedColorCount 回報,XLSX 寫入器則以 indexedColors 區塊輸出

為 XLS 存檔與 XLSX 轉 XLS 選擇加入

BiffPaletteSavePolicy 屬性預設為 xbpsPreserve,所以活頁簿的調色盤只在你要求時才會改變。設為 xbpsOptimizeTrueColors 會讓 Classic 活頁簿在 SaveAs 裡建立並套用一份新計畫,而且只在目標格式是 xlExcel97 時才會;BIFF5、CSV、HTML、PDF、XLSX 與其他寫入器都忽略這個設定。存檔成功後,最佳化過的調色盤會留在活頁簿裡,所以之後的查詢與存檔看到的是同一套對應。如果存檔失敗、被取消或丟出例外,原本的 56 個顏色與原本的世代都會還原

對程式產生的報表,把最佳化政策打開。這些活頁簿是從 RGB 與佈景主題色建出來的,沒有人手動調過它們的調色盤,而只看索引的讀取器有了它之後顯示的結果會接近原稿得多。至於調色盤本身就是契約一部分的活頁簿,例如下游程式碼會依 ColorIndex 編輯的範本,請保留 xbpsPreserve,因為一個槽位悄悄換了顏色會弄壞那段程式碼

XLSX 來源走另一條路。lxXlsxExport 裡的 SaveXLSXWorkbookAsXLS 從已載入的 TXLSXWorkbook 建一份計畫,並在轉換任何樣式之前把它寫進目的調色盤,所以每個轉換後的儲存格都是對著最終調色盤比對的。活頁簿稽核與轉換工作台示範 跑的正是這條路。佈景主題色在色調解析成 RGB 之後走同一個規劃器;應該維持為動態佈景主題參照的圖表填滿則是另一個案例,GelFrame 佈景主題色圖表填滿一文 有介紹,那裡的二進位 XLS 存的是配色索引而不是攤平的顏色

// Classic 活頁簿:選擇加入,只對 BIFF8 存檔生效
Workbook.BiffPaletteSavePolicy := xbpsOptimizeTrueColors;
if Workbook.SaveAs('report.xls', xlExcel97) <> 1 then
  HandleSaveFailure;   // 你自己的處理常式;調色盤已經還原

// XLSX 模型轉 BIFF8,使用單一確定性的調色盤計畫
XWorkbook := TXLSXWorkbook.Create;
try
  if XWorkbook.Open('report.xlsx') = 1 then
    if SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls') <> 1 then
      HandleSaveFailure;
finally
  XWorkbook.Free;
end;

這些調色盤呼叫(ResolveIndexedColor、NearestIndexedColor、ScanIndexedColorUsage、BuildBiffPalettePlan 與 ApplyBiffPalettePlan)在 IXLSWorkbook 與 TXLSXWorkbook 上簽章相同,Delphi 與 C++Builder 皆可使用。HotXLS Delphi Excel 元件頁面 上的試用版,就足以針對你最花俏的範本跑一份計畫,並在寫入任何東西之前先讀 MaxDistanceSquared