若以 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 對字型合法,對填滿則無效
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 兩套引擎給出相同答案
預設調色盤還多了一個皺褶。海軍藍出現兩次,一次在 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,只移動空閒的中心,直到指派不再變動
溢位路徑值得冷靜地看清楚。這裡的 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