HotXLS 把任意的 RGB 與主題色彩分兩層映射到 56 格的 BIFF8 調色盤上:NearestIndexedColor 在 OKLab 空間裡找出感知上最接近的既有調色盤項目,而 BuildBiffPalettePlan 加上 ApplyBiffPalettePlan 則重寫空閒的調色盤槽位,讓全彩活頁簿存成傳統 XLS 之後色彩還保得住。觸發點永遠是同一種支援請求:有人用 XLSX 做了一份報表,表頭是企業深藍、強調色是柔和的藍綠,為了餵給舊系統而存成 .xls,結果表頭回來變成純黑,藍綠則被換成刺眼的青綠色。什麼都沒當掉,也沒有任何警告。舊格式的色彩模型就是裝不下新格式描述的東西,而函式庫總得挑個值
為什麼 XLS 檔案只能裝 56 色?
因為 BIFF8 的儲存格格式從不儲存 RGB 值:字型、填色與框線帶的是色彩索引,而活頁簿全域的 Palette 記錄($0092,[MS-XLS] §2.4.188)恰好為索引 8 到 63 提供了 56 個不透明的 RGB 項目。索引 0 到 7 是八個基本色的固定副本,63 以上的值根本不是色彩,而是系統前景、系統背景與圖表文字這類 token。HotXLS 透過 1 到 56 的公開 ColorIndex 暴露調色盤,也就是實體索引減 7,而 ResolveIndexedColor 靠 TXLSIndexedColorSpace 把三套編號方案分開:xicsPublicColorIndex 對應 1..56 的 API 值,xicsBiffIcv 對應磁碟上的原始索引,會依您傳入的角色對 IcvFont、IcvXF 或 IcvChart 子集做驗證,還有 xicsOoxmlIndexed,其中 64 與 65 代表系統前景與背景
var
Res: TXLSIndexedColorResolution;
begin
// $40 是 BIFF icv token,不是調色盤槽位
Workbook.ResolveIndexedColor($40, xicsBiffIcv, Res);
case Res.Kind of
xickPalette: UseArgb(Res.ARGB); // 調色盤槽位,若解析得出
xickAutomatic,
xickSystem: UseSystemColor(Res.SystemColorRole);
xickInvalid: RejectToken(Res.RawIndex);
end;
end;
注意範例是對 Res.Kind 做 switch,無視布林回傳值。ResolveIndexedColor 只有在拿到具體 ARGB 時才回傳 True,而短多載從不去讀 Windows 桌面,所以 automatic 或 system token 合情合理地回傳 False、卻仍被歸類為 xickSystem。HotXLS 自己的活頁簿序列化器就踩過這個坑:把 False 當成「沒有色彩」的程式碼,會默默丟掉 token 身為 Automatic 與 System 的語意。如果您需要這些 token 的真實 RGB 值,就呼叫長多載並提供一個 TXLSTryResolveSystemColor Callback,套用您自己的 UI、匯出或 headless 政策
HotXLS 為什麼用 OKLab 而不是 RGB 來比對色彩?
因為 sRGB 通道值經過 gamma 編碼,RGB 裡的歐氏距離並不追蹤人眼所見,而誤差最嚴重的恰好是企業調色盤最愛的深色、高飽和色調。拿深藍 $000033 來說。在 RGB 裡,它到黑色的距離是 51、到預設藏青項目 $000080 的距離是 77,所以 RGB 比對器會很有自信地把您的表頭塗成黑色。在 OKLab 裡,距離平方分別約是到黑色 0.0312、到藏青 0.0235,HotXLS 便選了藏青,也就是實體槽位 18 的 ColorIndex 11;這個確切案例在 Classic 與 XLSX 兩個引擎的測試套件裡都被釘死。ArgbToOklab 內部的轉換會把每個 sRGB 通道線性化、套上 OKLab 的 LMS 矩陣、開立方根,再投影到 L、a、b,之後單純的歐氏距離平方就足以當作感知差異的合理代理。OKLab 不是 CIEDE2000,也不假裝是,但它沒有分段式的色相修正、每個色彩只花幾次乘法,而且穩定到足以驅動分群迴圈——這才是它真正價值所在
NearestIndexedColor 保證了什麼?
NearestIndexedColor 保證的是決定性、唯讀的答案:一次輸入轉換、一趟對 56 個快取項目的固定掃描,以及兩個項目同樣接近時取較低的公開索引。每本活頁簿會把 56 個實體槽位的正規化 ARGB 與 OKLab 座標連同一個調色盤世代計數器一起快取。重設調色盤會重建快取,改動單一槽位只更新該槽位,而對過期世代的查詢會回傳 False、絕不瞎猜。掃描從槽位 8 開始、用嚴格小於比較,所以同一個色彩在調色盤裡出現兩次時永遠回報較低的索引;當您在 diff 兩個產出檔案、期望逐位元組相同的輸出時,這點就很要緊。輸入的 alpha 遵循窄契約:alpha 位元組為零視為不透明,部分透明的值則以 ColorIndex 0 與 PaletteSlot -1 拒絕,因為調色盤項目沒有 alpha。Classic 引擎的填色與框線寫入器在存檔時,用同一套 OKLab 比對常式把 RGB 與主題色彩轉成索引,所以 API 與存出來的檔案對「色彩落在哪個槽位」的答案一致
var
Match: TXLSNearestIndexedColorMatch;
begin
if Workbook.NearestIndexedColor($FF000033, Match) then
begin
// 結果是 ColorIndex = 11、PaletteSlot = 18、ARGB = $FF000080
if not Match.ExactMatch then
LogApproximation(Match.InputARGB, Match.ARGB, Match.DistanceSquared);
end;
end;
BuildBiffPalettePlan 怎麼把全彩塞進 56 個槽位?
BuildBiffPalettePlan 會為全部 56 個槽位算出一份完整提案,而且完全不碰活頁簿,所以您可以檢查、記錄或直接丟棄它。規劃器先呼叫 ScanIndexedColorUsage:凡是字型、填色、框線、條件式格式、圖案、註解或工作表格線以索引參照到的槽位一律鎖定,因為改一個調色盤項目會同時替該索引的所有消費者重新上色。目標則是來自字型、填色、框線、差異化樣式、資料橫條與色階的直接 RGB 與解析後的主題色彩。每個目標的權重取其呈現參照次數與定義次數的較大者,而條件式格式會把其範圍涵蓋的儲存格數算進去,所以塗滿整欄的色彩遠比只在單一註解裡出現的色彩重。接著放置程序按固定順序進行:
- 鎖定的槽位無條件保留來源色彩
- 調色盤裡已經存在的目標,保留在最低的相符槽位上,且該槽位就此固定
- 如果其餘的唯一目標放得進空閒槽位,就逐一給予確切槽位,按 ARGB 遞增排序指派
- 否則就設起
Quantized,為每個空閒槽位挑「到最近既有中心點的距離乘上自身權重」最大的目標做種子,再由最多 16 回合的頻率加權 k-means 在 OKLab 裡只移動空閒中心點,直到指派不再變動為止
對溢出路徑端出什麼,請誠實面對。分群是有界的局部最佳化,不是全域最佳,而空閒槽位最後裝的是質心轉回 sRGB 經夾限後的值,可能是沒有任何儲存格逐字用過的色彩。您真正得到的是可重現性:同一本活頁簿永遠產出同一份計畫,而計畫會透過 WeightedError、MaxDistanceSquared、ExactTargetWeight 與 TotalTargetWeight 回報自身損害,所以批次工作可以在近似粗到違反品牌規範時拒絕存檔
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 雜湊;驗證還會重新檢查每個公開與實體索引、每個來源色彩、沒有任何鎖定槽位被標成 changed、鎖定與變更的計數,以及每個目標都是不透明。中間發生的任何實質調色盤變動,包括同一份計畫先前一次成功的套用,都會讓計畫過期,所以計畫實質上是一次性的。沒有任何槽位變更的有效計畫會成功且不推進世代,真正的變更則把世代推進一次、重建一次 OKLab 比對器——Classic 引擎靠重寫固定的調色盤陣列,XLSX 引擎靠換上一份備妥的索引色彩覆寫清單
為 BIFF8 存檔與 XLSX 轉 XLS 轉換開啟它
BiffPaletteSavePolicy 屬性預設是 xbpsPreserve,所以升級 HotXLS 絕不會在您不知情的情況下重寫任何人的調色盤。設成 xbpsOptimizeTrueColors 之後,Classic 活頁簿會在 SaveAs 裡建置並套用一份全新計畫,但僅限目標格式是 xlExcel97 時;BIFF5、CSV、HTML、PDF、XLSX 與其他寫入器都無視這個設定。存檔成功之後,最佳化過的調色盤會留在活頁簿模型裡,之後的查詢與存檔看到的是同一套映射。如果存檔失敗或被取消,原始 56 色與原始世代會被還原。對 XLSX 來源,lxXlsxExport 裡的 SaveXLSXWorkbookAsXLS 會從載入的活頁簿建出一份計畫,在任何樣式被轉換之前就寫進目的端調色盤,活頁簿稽核與轉換工作台示範演練的正是這座決定性橋梁。主題色彩在其 tint 解析成 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
SaveXLSXWorkbookAsXLS(XWorkbook, 'report.xls');
finally
XWorkbook.Free;
end;
HotXLS 的調色盤 API 在 IXLSWorkbook 與 TXLSXWorkbook 上行為一致,Delphi 與 C++Builder 皆可使用。歡迎從 HotXLS Delphi Excel 元件產品頁下載試用版,拿您色彩最豐富的試算表來試試