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 做分支、忽略了 Boolean 返回值。ResolveIndexedColor 只在拿到具体 ARGB 时才返回 True,而短重载从不读 Windows 桌面,所以 automatic 或 system token 合法地返回 False、同时仍被归类为 xickSystem。HotXLS 在自己的工作簿序列化器里就踩过这个坑:把 False 当「没有颜色」处理的代码悄悄丢掉了 token 的 Automatic 和 System 语义。如果你需要这些 token 的真实 RGB 值,就调长重载并提供一个 TXLSTryResolveSystemColor 回调,套用你自己的 UI、导出或无头策略
HotXLS 为什么在 OKLab 而不是 RGB 里匹配颜色?
因为 sRGB 通道值是 gamma 编码的,RGB 里的欧氏距离跟不上人眼看到的东西,而且误差最大的恰恰是企业调色板最爱的那些深色、饱和色调。拿深蓝 $000033 来说:在 RGB 里它到黑色的距离是 51,到默认 navy 条目 $000080 的距离是 77,一个 RGB 匹配器会信心满满地把你的表头涂成黑色。在 OKLab 里,到黑色的距离平方约 0.0312,到 navy 约 0.0235,于是 HotXLS 选了 navy,也就是物理槽位 18 上的 ColorIndex 11;这个具体用例被钉在测试套件里,Classic 和 XLSX 引擎都要过。ArgbToOklab 内部的转换先线性化每个 sRGB 通道,套上 OKLab 的 LMS 矩阵,开立方根,再投影到 L、a、b,之后一个普通的欧氏距离平方就是感知差异的合理代理。OKLab 不是 CIEDE2000,它也不装作是,但它没有分段的色相修正,每个颜色只花几次乘法,而且稳定到足以驱动一个聚类循环——这才是它真正值钱的地方
NearestIndexedColor 保证什么?
NearestIndexedColor 给出的是确定性的、只读的答案:输入只转换一次,对 56 个缓存条目做一次固定扫描,两个条目同样接近时取较低的公开索引。每个工作簿会把全部 56 个物理槽位的归一化 ARGB 和 OKLab 坐标连同调色板 generation 计数器一起缓存。重置调色板会重建缓存,改单个槽位只更新那一个槽位,拿着过期的 generation 来查询会返回 False 而不是瞎猜。扫描从槽位 8 开始用严格的小于比较,所以一张包含同一颜色两次的调色板总会回答较低的索引;当你 diff 两个生成出来的文件、期望逐字节相同的输出时,这一点很要紧。输入 alpha 遵守一个狭窄的契约:alpha 字节为 0 视为不透明,半透明的值会被拒绝并返回 ColorIndex 0 和 PaletteSlot -1,因为调色板条目没有 alpha。Classic 引擎的填充和边框写入器在保存时用同一套 OKLab 匹配例程把 RGB 和主题色转成索引,所以 API 和存出来的文件在颜色落在哪个槽位上说的是同一句话
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;
BuildBiffPalettePlan 怎么把真彩色塞进 56 个槽位?
BuildBiffPalettePlan 会为全部 56 个槽位算出一份完整提案,而不碰工作簿本身,所以你可以检查它、记日志或直接丢弃。规划器先调 ScanIndexedColorUsage:凡是字体、填充、边框、条件格式、形状、批注或工作表网格线按索引引用到的槽位一律锁定,因为改一个调色板条目会立刻给该索引的每个使用方重新着色。目标则是来自字体、填充、边框、differential styles、data bars 和 color scales 的直接 RGB 与解析后的主题色。每个目标的权重取其渲染引用计数和定义计数中的较大者,条件格式按其区域覆盖的单元格数计,于是涂满一整列的颜色比只在某条批注里出现一次的颜色分量重。放置按固定顺序进行:
- 锁定的槽位无条件保留其源颜色
- 调色板里已存在的目标保留在其最低的匹配槽位上,该槽位随之固定
- 如果剩下的去重目标装得进空闲槽位,每个都拿到一个精确槽位,按 ARGB 升序分配
- 否则置上
Quantized,每个空闲槽位用「到最近现有中心的距离乘以权重」最大的那个目标做种子,然后在 OKLab 里跑最多 16 轮按频率加权的 k-means,只移动空闲中心,直到分配不再变化
对溢出路径能交付什么,你得对自己诚实。这个聚类是有界的局部优化,不是全局最优,一个空闲槽位最终装的是转换回 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 哈希;校验还会复查每个公开与物理索引、每个源颜色、没有任何锁定槽位被标记为已改变、锁定与改变的计数,以及每个目标都是不透明的。期间任何有效的调色板变更——包括同一份计划早前一次成功的应用——都会让计划过期,所以计划实际上是单次使用的。没有槽位被改变的有效计划会成功返回且不推进 generation,而真正的变更会把 generation 推进一次、把 OKLab 匹配器重建一次:Classic 引擎通过重写固定调色板数组,XLSX 引擎通过换入一份准备好的索引颜色覆盖列表
在 BIFF8 保存和 XLSX 转 XLS 时把它打开
BiffPaletteSavePolicy 属性默认是 xbpsPreserve,所以升级 HotXLS 永远不会背着谁重写调色板。把它设成 xbpsOptimizeTrueColors 会让 Classic 工作簿在 SaveAs 内部构建并应用一份新计划,但仅当目标格式是 xlExcel97 时才生效;BIFF5、CSV、HTML、PDF、XLSX 和其他写入器都忽略这个设置。保存成功后,优化过的调色板留在工作簿模型里,之后的查询和保存看到的都是同一套映射。如果保存失败或被取消,原始的 56 个颜色和原始 generation 会被恢复。对 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 组件页面