按 RGB 通道值取朴素欧氏距离度量,深蓝 $000033 离黑色 51 个单位、离经典 navy 条目 $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 表示 automatic。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 的 Boolean 返回值是调用方栽跟头的地方。只有拿到具体 ARGB 值它才返回 True,而短重载从不向操作系统询问桌面颜色。所以 system 或 automatic 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、离 navy 77,黑色胜出。把每个通道线性化、把颜色投影进 OKLab 之后,距离平方约为到黑色 0.0312、到 navy 0.0235,navy 胜出。匹配到的是物理槽位 18 上的 ColorIndex 11,Classic 和 XLSX 引擎给出同一答案
默认调色板还添了个褶皱。navy 出现了两次,在 ColorIndex 11 和 25 各一次,所以 $000033 到两者一样近。扫描顺序不同、或用了小于等于比较的匹配器,会在不同构建里一次挑 25、一次挑 11,两份生成的文件从此 diff 不干净。HotXLS 从槽位 8 起按严格小于扫描,所以平手永远归较低的索引
选 OKLab 是一笔刻意的交换。CIEDE2000 对某些色相对的排序更准,但它的分段修正又慢又难在迭代循环里推演。OKLab 每个颜色花一次线性化、两次小矩阵乘和三次立方根,之后一个普通的距离平方就足以充当感知差异的代理。对一次在每次颜色赋值和聚类环节都要跑的 56 条目调色板扫描,更便宜、更平滑的度量才是对的选择
NearestIndexedColor 返回什么?
NearestIndexedColor 接收一个 ARGB 值并填一份 TXLSNearestIndexedColorMatch 记录,不改动工作簿。每个工作簿把 56 个调色板颜色连同它们的 OKLab 坐标和一个调色板 generation 计数器一起缓存,所以一次查询只花一次输入转换和一趟固定扫描。重置调色板会重建缓存,改单个条目只更新那个槽位,缓存与当前 generation 脱节时查询返回 False 而不是瞎猜
alpha 遵守一条窄规则。alpha 字节为 0 视为不透明——这与多数 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 setter 背后也是同一个匹配器。在 Classic 工作簿上给 Interior.Color、Font.Color 或 Border.Color 赋值,会把精确颜色留给 XFExt 记录,同时用这次扫描挑出存进 XF 记录的索引;填充和边框写入器也用同样的方式把 RGB 和主题色解析成索引。NearestIndexedColor 对某个颜色报什么槽位,只认索引的读取器显示的就是什么
把真彩工作簿塞进 56 个槽位
对少数几种颜色,对着默认调色板匹配就够了。一个带着十来种相近深蓝的品牌模板需要更多:调色板本身得变。BuildBiffPalettePlan 为全部 56 个槽位算出一份完整提案,且不碰工作簿,你可以记日志、对着阈值比较或直接扔掉
规划器从 ScanIndexedColorUsage 起步。凡是被字体、填充、边框、条件格式、形状、批注、图表字体或工作表网格线按索引引用到的槽位一律锁定,因为改那个条目会立刻给该索引的所有使用方重新着色。目标则来自字体、填充、渐变停靠点、边框、条件格式样式、data bars 和 color scales 的直接 RGB 与解析后的主题色。每个目标的权重取其引用计数与定义计数中较大者,条件格式按其区域覆盖的每个单元格计,所以涂满一整列的填充比只在一条批注里出现一次的颜色分量重。放置随后按固定顺序进行:
- 锁定的槽位无条件保留当前颜色
- 调色板里已存在的目标保留其最低匹配槽位,该槽位随之固定
- 如果剩余的去重目标装得进空闲槽位,按 ARGB 升序每个分得一个精确槽位
- 否则置上
Quantized:每个空闲槽位用“到最近既有中心距离乘以权重”最大的目标做种子,然后在 OKLab 里跑最多 16 轮按权重加权的 k-means,只移动空闲中心,直到分配不再变化
溢出路径值得清醒地读一遍。这里的 k-means 是有界的局部优化,不是全局最优;一个空闲槽位最终装的是转换回 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 哈希。校验还会复查每个公开与物理索引、每个源颜色、没有任何锁定槽位被标记为已改变、锁定与改变的计数对得上,以及每个目标都不透明
规划与应用之间发生的任何真实调色板变更都会让计划过期——包括同一份计划早前一次成功的应用。计划实际上是单次使用的:构建、检查、应用,需要再来一份就重新构建。没有槽位改变的有效计划成功返回且不推进 generation。真实变更会把 generation 推进一次、把匹配器刷新一次。Classic 引擎上新颜色直接进调色板;XLSX 引擎上它们变成自定义索引色,由 HasCustomIndexedColor 和 CustomIndexedColorCount 报告,XLSX 写入器以 indexedColors 块写出
为 XLS 保存和 XLSX 转 XLS 打开开关
BiffPaletteSavePolicy 属性默认是 xbpsPreserve,所以工作簿的调色板只在你说要改时才改。设成 xbpsOptimizeTrueColors 会让 Classic 工作簿在 SaveAs 内部构建并应用一份新计划,且仅当目标格式是 xlExcel97;BIFF5、CSV、HTML、PDF、XLSX 和其他写入器都忽略这个设置。保存成功后,优化过的调色板留在工作簿里,之后的查询和保存看到的是同一套映射。保存失败、取消或抛异常时,原始 56 色与原始 generation 被恢复
对生成型的报表,把优化策略打开。这类工作簿由 RGB 和主题色构建,没人手工调过调色板,打开之后只认索引的读取器显示它们会明显更接近原稿。xbpsPreserve 留给调色板属于契约一部分的工作簿——比如下游代码按 ColorIndex 编辑的模板,某个槽位悄悄变色会弄坏那段代码
XLSX 源走另一条路。lxXlsxExport 里的 SaveXLSXWorkbookAsXLS 从加载好的 TXLSXWorkbook 构建一份计划,在任何样式转换之前先写进目标调色板,所以每个被转换的单元格都是对着最终调色板匹配的。工作簿审计与转换工作台演示 跑的正是这条路径。主题色在其 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
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