技术文章

Delphi Excel 图表主题颜色与 HotXLS GelFrame

使用字面 RGB 填充的图表序列不会跟随工作簿主题。主题发生变化时,序列仍然保留旧颜色。HotXLS 在二进制 XLS 中通过主题颜色图表序列填充处理这一点:在序列块的 AreaFormat 之后写入一个 GelFrame 记录,即 4198 或 $1066,其中携带 OfficeArt 方案索引和色调。Excel 随后会像渲染自己写出的主题填充一样渲染该序列

GelFrame 记录号从哪里来

GelFrame 记录号是 4198($1066),而且你不会在该记录自身的规范章节中找到它。[MS-XLS] 2.4.131 描述了 GelFrame 包含什么,但与大多数记录章节不同,它没有说明 rt 值。图表子流 ABNF 也没有帮助:它只给出 GELFRAME = 1*2GelFrame *Continue 这个产生式,只命名记录而不编号。编号位于记录号枚举表中,距离载荷说明章节有数页。对编写读取器的人来说,这个产生式值得再看一遍:它允许一个或两个 GelFrame 记录,每个记录后面还可以有 Continue 记录,因此假定一个产生式只对应一条记录的解析器,会错误处理自己没有写出的文件。HotXLS 为每个主题序列恰好发出一个 GelFrame,这是 Excel 对简单纯色主题填充的输出方式;其解码器将记录作为自包含载荷处理,而不是假定固定数量

GelFrame 载荷内部:两个 OfficeArt 属性表

GelFrame 载荷是两个前后相接的 OfficeArt 属性表:OfficeArtFOPT(称为 OPT1)后跟 OfficeArtTertiaryFOPT(OPT2)。每张表由一个两字节属性计数和对应数量的六字节 FOPTE 条目组成,每个条目是一个两字节 opid 加一个四字节 opopid 的第 15 位是 fComplex:设置后,op 值表示字节长度,固定条目之后还会跟着可变尾部。忽略这些尾部的解码器会失去同步,在第一个复杂属性之后为后续所有内容读取出垃圾 opid

主题填充由分布在两张表中的三个属性以及一个声明填充类型的属性表达。HotXLS 在 28 字节中写入四个属性,不包含复杂尾部:

  • OPT1 中的 fillType $0180,设置为 1(msofillSolid
  • OPT1 中的 fillColor $0181,表示旧版或不理解主题的消费者要绘制的扁平 RGB
  • OPT2 中的 fillColorExt $019E,表示基础主题颜色
  • OPT2 中的 fillColorExtMod $01A0,表示施加到基础颜色上的色调或阴影

这种拆分是格式有意设计的,而不是实现偶然。[MS-ODRAW] 2.2.2 将主题三元组描述为扁平颜色、基础颜色和修改量,因此理解主题的消费者会重新计算填充,不理解主题的消费者仍然能绘制出合理内容。周围的 opid 遵循同一模式,在旧版和当前版 [MS-ODRAW] 中编号也相同,这在交叉阅读两个版本时很方便:fillOpacity $0182、fillBackColor $0183、fillShadeType $019C、fillBackColorExt $01A2 和 fillBackColorExtMod $01A4

方案索引为什么位于红字节中

因为 OfficeArtCOLORREF 按字节偏移定义,而不是按数值定义:红色位于字节 0,绿色位于字节 1,蓝色位于字节 2,标志位于字节 3。把这个结构作为小端 DWORD 读取,而每个 FOPTE op 正是这样读取的,红色就会成为最低有效字节。[MS-ODRAW] 中的 lineColor 工作示例证实了这一点。因此 fSchemeIndex(标志位 E)的数值是 $08000000,方案索引本身放入红字节,绿色和蓝色必须为零。Accent1 的 op 值因此是 $08000004,而不是 $00000004,更不是 $04000000

规范拒绝定义的主题索引顺序

规范把方案索引顺序称为由宿主定义,并不给出表格,这意味着仅凭字节布局还不足以和 Excel 互操作。HotXLS 使用电子表格主题顺序,这正是与真实 Excel 文件往返时采用的顺序:

  • 0 = lt1,1 = dk1,2 = lt2,3 = dk2
  • 4 到 9 = accent1 到 accent6
  • 10 = hlink,11 = folHlink

色调和阴影:MSOTINTSHADE 载荷

fillColorExtMod op 是一个 MSOTINTSHADE 值,它用一个 DWORD 同时编码方向和幅度,而不是使用有符号分数。值 $20000000 表示未修改。变亮色调是 $02F4 shl 16 or amount shl 8 or $10(MSOTINT);变暗色调的形状相同,只是高字使用 $01F4(MSOSHADE)。amount 字节的方向违反直觉:$FF 表示不变,$00 表示完整修改。HotXLS 将其规范化为一个 DrawingML 风格的 double,正值变亮,负值变暗,使用加号或减号 (255 - amount) / 255。对于 Excel 界面实际提供的数值,这个映射是精确的,因此往返是无损而不是近似无损:“Lighter 40%”对应 amount 153,(255 - 153) / 255 在任一方向都得到 0.4,不发生舍入误差。amount 为 191 的阴影会恢复为 -64/255。下面是限制在合法范围内的编码器:

if Tint > 0 then                       // MSOTINT - 变亮
  TintOp := LongWord($02F4) shl 16 or
    (LongWord(Round(255 * (1 - Tint))) shl 8) or $10
else if Tint < 0 then                  // MSOSHADE - 变暗
  TintOp := LongWord($01F4) shl 16 or
    (LongWord(Round(255 * (1 + Tint))) shl 8) or $10
else
  TintOp := $20000000;                 // MSOCOLORMODUNDEFINED

在 Delphi 中设置和读取主题填充

写入侧的主题填充是每个序列样式记录上的两个额外字段。TXLSChartSeriesStyleInfo 增加了 HasFillThemeFillThemeColorFillThemeTint,并且只有在 HasStyleHasFillTheme 都设置时,构建器才会发出 GelFrame。如果同时设置显式 FillRgb,该值会原样进入 OPT1 的 fillColor;如果没有设置,HotXLS 会通过内置的默认 Office 主题表并应用色调自行扁平化颜色,因此只有主题的序列对忽略 OPT2 的消费者也有合理的扁平颜色。要注意 Default() 初始化,这很重要,因为 TXLSChartSeriesInfo 含有受管理字段,否则它的普通 Boolean 成员就是栈垃圾:

var
  Wb: TXLSWorkbook;
  Series: array [0..1] of TXLSChartSeriesInfo;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Sheets.Add.Name := 'Data';

    Series[0] := Default(TXLSChartSeriesInfo);   // 绝不要对这个记录使用 FillChar
    Series[0].Name := 'Explicit';
    Series[0].Categories := 'Data!$A$1:$A$2';
    Series[0].Values := 'Data!$B$1:$B$2';
    Series[0].HasStyle := True;
    Series[0].Style.HasFill := True;
    Series[0].Style.FillRgb := $C47244;          // accent1,红色位于低字节
    Series[0].Style.HasFillTheme := True;
    Series[0].Style.FillThemeColor := 4;         // accent1
    Series[0].Style.FillThemeTint := 0.4;        // Lighter 40%

    Series[1] := Default(TXLSChartSeriesInfo);
    Series[1].Name := 'ThemeOnly';
    Series[1].Categories := 'Data!$A$1:$A$2';
    Series[1].Values := 'Data!$C$1:$C$2';
    Series[1].HasStyle := True;
    Series[1].Style.HasFillTheme := True;        // 没有显式 RGB:会扁平化
    Series[1].Style.FillThemeColor := 8;         // accent5

    Wb.Sheets.AddChartSheet('Themed', xlsChartTypeColumn, '', '', '', Series);
    Wb.SaveAs('themed.xls');
  finally
    Wb.Free;
  end;
end;

读取时会经过 HotXLS 其余图表检查使用的同一个图表模型。GetChartModel 返回由调用方拥有、需要调用方释放的 TXLSChartModel,每个 TXLSChartSeries 都会公开 HasFillThemeFillThemeColorFillThemeTint,同时还公开从 OPT1 fillColor 解码的 FillRgb;对该序列而言,后者优先于 AreaFormat 颜色。同样的三个值也会进入规范语义快照,成为 SolidFillThemeSetSolidFillThemeColorSolidFillThemeTint,因此工作簿差异会把主题变化看作主题变化,而不是无法解释的 RGB 漂移。如果你来自 XLSX 侧,这就是HotXLS Delphi Excel 图表、图像和绘图指南中样式的二进制格式对应物:

Wb := TXLSWorkbook.Create;
try
  Wb.Open('themed.xls');
  Model := Wb.Sheets[2]._Chart.GetChartModel;
  try
    Ser := Model.GetSeries(0);
    if Ser.HasFillTheme then
    begin
      WriteLn(Ser.FillThemeColor);            // 4 = accent1
      WriteLn(Ser.FillThemeTint:0:3);         // 0.400
      WriteLn(IntToHex(Ser.FillRgb, 6));      // C47244,OPT1 的 fillColor
    end;
  finally
    Model.Free;
  end;
finally
  Wb.Free;
end;

二进制 XLS 中的主题填充不承诺什么

有三个诚实的限制。第一,也是审查这段代码的人最需要知道的:本地语料库中没有任何示例文件包含 GelFrame 记录。条件格式示例中出现的 11 个字节对 66 10 都不在记录边界处,完整流记录转储也找不到任何命中。这里描述的位布局来自规范,然后通过三种方式固定下来:对构建器输出执行解码对称性检查,使用手工构建的字节测试将合成的 $1066 载荷直接送进解码器,以及断言精确的扁平 RGB。这比捕获 Excel 文件弱一层证据,因此值得说明,而不是暗示相反结论。第二,只有主题填充的扁平化使用内置默认 Office 主题表,而不是读取工作簿中的主题部件,因为二进制 XLS 没有类似打包 XLSX 的主题部件——如果需要工作簿自身的主题驱动扁平颜色,请自行提供 FillRgb。第三,解码器只接受序列块中的 GelFrame;同一记录也可能出现在图表区域或轴框中,若在那里接受,就会把背景填充静默归因给序列,因此这些位置会被忽略。没有 $08000000 标志的 fillColorExt 同样会被视为普通扩展颜色,不会设置 HasFillTheme。如果工作簿中的图表是在 XLSX 世界创建、只是经过这里,那么编辑 Excel 图表而不丢失 ChartML的保留路径更安全;这些记录所在的容器见在 Delphi 中不使用 COM IStorage 读取 OLE2 复合文件

主题颜色图表填充、GelFrame 编码器和解码器,以及完整的 BIFF8 图表子流构建器,都包含在面向 Delphi 和 C++Builder 的 HotXLS Delphi 电子表格组件中,无需安装 Excel 即可读写 XLS、XLSX 和 ODS