技术文章

Delphi HotXLS BIFF8 图表次坐标轴组

HotXLS 通过发出第二个 AxisParent 块来写入 BIFF8 图表次坐标轴组,而不是在轴之后追加第二个图表组。在 Classic XLS 图表子流中,每个图表组——ChartFormat、图表类型记录和 CrtLink——都位于自己的轴组块内,每个序列通过 SerToCrt 绑定到其中一个组。把这个嵌套关系写反后,无论发出多少轴记录,都不会有第二个图表组可供序列绑定

为什么轴之后的第二个图表组什么也绑定不到

答案就在语法中,而且只有一行 ABNF。[MS-XLS] 2.1.7.20.1 的 CHARTFOMATS 规则写着 AxesUsed 1*2AXISPARENT,随后又明确写出 AXISPARENT = AxisParent Begin Pos [AXES] 1*4CRT End。把这两个产生式放在一起读,结构就清楚了:图表组是轴组的子项,不是轴组的兄弟项。带有两对轴和一个尾随图表组的图表子流,并不是布局有点奇怪的双轴图表,而是一个带有一个图表组和一组孤立轴记录的图表。这很重要,因为位于 SERIESFORMAT 块中的 SerToCrt($1045)携带的是从 0 开始的图表组索引,而不是轴索引。当只存在一个 CRT 块时写入 crt = 1,就会把序列指向一个从未发出的图表组。让人误判的直觉来自记录名:AXESUSED($1046)听起来像是在计数轴,于是自然的下一步就是发出更多轴。实际上它计算的是轴组,每个轴组都会带来完整的绘图区和图表组

把序列标记到次坐标轴组

在 HotXLS 侧,这归结为一个 Boolean。TXLSChartSeriesInfo 带有 SecondaryAxis 字段,在传给 TXLSWorksheets.AddChartSheet 的数组中为任一序列设置它,就会把整个构建器切换到双组模式。没有单独的“启用次坐标轴”调用,也没有轴计数参数,因为计数可以推导:只要有任一序列需要次组,图表就需要两个组

var
  Wb: TXLSWorkbook;
  Series: array [0..1] of TXLSChartSeriesInfo;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Sheets.Add.Name := 'Data';
    // ... 用类别、收入和利润率填充 A1:C12 ...

    Series[0] := Default(TXLSChartSeriesInfo);   // 绝不要对这个记录使用 FillChar
    Series[0].Name := 'Revenue';
    Series[0].Categories := 'Data!$A$1:$A$12';
    Series[0].Values := 'Data!$B$1:$B$12';

    Series[1] := Default(TXLSChartSeriesInfo);
    Series[1].Name := 'Margin';
    Series[1].Categories := 'Data!$A$1:$A$12';
    Series[1].Values := 'Data!$C$1:$C$12';
    Series[1].SecondaryAxis := True;             // AXESUSED 变成 2

    Wb.Sheets.AddChartSheet('Dual Axis', xlsChartTypeLine,
      'Revenue vs Margin', '', '', Series);
    Wb.SaveAs('dual-axis.xls', xlExcel97);
  finally
    Wb.Free;
  end;
end;

Default(TXLSChartSeriesInfo) 这一行不是装饰。TXLSChartSeriesInfo 将受管理字段(WideString 名称、动态趋势线和误差线数组)与普通 Boolean 成员混在一起,而 Delphi 只保证会为你清除受管理字段。如果不初始化 SecondaryAxis,它就是栈上的任意值,实际结果可能是同一个二进制文件在控制台宿主中运行时产生单轴图表,在测试运行器下却产生双轴图表。另一方面,类别和值范围会在构建器看到它们之前通过工作簿的 EXTERNSHEET 表解析——这与HotXLS 如何分类 BIFF SupBook 和 XTI 外部链接中介绍的同一索引机制有关——因此引用未知工作表的范围会退化为一个空的 BRAI 占位符,而不是让构建失败

序列为次坐标轴时 HotXLS 会发出什么

发射器改变的是形状,而不仅仅是一个值。没有次序列时,AXESUSED 为 1(饼图和 3D 饼图没有轴组,因此为 0),随后跟着一个 AxisParent 块。有次序列时,AXESUSED 为 2,构建器运行两次该块,iax——18 字节 AxisParent($1041)载荷的第一个字——先设为 0,再设为 1。每次都会发出 Pos、类别轴和值轴(Axis,$101D)、PlotArea 标记($1035)、默认 Frame,然后是 ChartFormat($1014)、图表类型记录、CrtLink($1022),以及两个 End 标记来关闭图表组和轴组。次序列随后以 SerToCrt crt = 1 绑定,主序列继续使用 crt = 0。有一条记录有意不复制:图例只在第一组发出,因为无论带多少轴组,Excel 都给一个图表一个图例。还有两个属性值得直说。重构发射器、把图表组放进轴父块后,普通图表的输出没有改变——没有次序列时,子流与旧版本逐字节相同,因为将 AddAxisParent 参数化为 iax = 0 正是原来的代码路径。构建器仍会为每个组发出完整的轴对,因此次组始终带有自己的类别轴,即使你只关心它的数值刻度

图表检查如何恢复轴组绑定

读取需要对记录列表执行两遍,因为 AXESUSED 出现在它描述的块之前。第一遍只寻找 $1046,并将其第一个字读取为轴组计数。该值从 1 开始,只增不减:HotXLS 取当前计数和声明计数的最大值,因此格式错误或重复的 AXESUSED 不会让已经发现声明为两个组的图表退回。第二遍跟踪当前轴组,在每个 AxisParent 处更新,并将该索引写入它遇到的每条 Axis 记录,直到下一个 AxisParent 出现

var
  Model: TXLSChartModel;
  i: Integer;
begin
  // Sheets[1] 是数据工作表,Sheets[2] 是图表工作表
  Model := Wb.Sheets[2]._Chart.GetChartModel;
  try
    if Model.AxisGroupCount = 2 then
      Writeln('AXESUSED declares a secondary axis group');
    for i := 0 to Model.AxisCount - 1 do
      Writeln('axis ', i, ' group ', Model.GetAxis(i).AxisGroup);
    for i := 0 to Model.SeriesCount - 1 do
      Writeln('series ', i, ' chart group ', Model.GetSeries(i).ChartGroup);
  finally
    Model.Free;
  end;
end;

有两个限制值得命名。TXLSChartModel.AxisGroupCount 报告的是文件声明的数量,而不是实际找到的 AxisParent 块数量;一个声明为 2 却只提供一个块的文件会报告 2,而 AxisCount 才是发现问题的位置。TXLSChartAxis.AxisGroup 则是位置标记:它记录轴是在读取哪个块时遇到的,这就是格式唯一告诉你的信息。在序列侧,SerToCrt 解码只有位于 Series 块内部时才会启用,因为同一个记录 ID 也会出现在不是序列绑定的上下文中;没有上下文限制的解码器会轻易覆盖错误的序列

没有真实 Excel 文件时如何验证次坐标轴

这里的验证不需要带次坐标轴的 Excel 文件,这正是故事中有用的部分。结构解码是记录序列的属性,因此合成序列和捕获序列一样可以精确证明它。回归测试构建载荷为 2 的 AXESUSED,然后构建两个 AxisParent 块,每个块包住一个类别轴和一个值轴,并断言模型返回 AxisGroupCount = 2,四个轴的标记依次为 0、0、1、1,第二对轴的类型也符合预期

// 完全不需要 Excel 文件的结构验证
Chart := TXLSCustomChart.Create(nil, $0600);
try
  AddWordRecord($1046, [2]);   // AXESUSED:两个轴组
  AddAxisParentGroup(0);       // AxisParent iax=0 + Begin + 2 Axis + End
  AddAxisParentGroup(1);       // AxisParent iax=1 + Begin + 2 Axis + End

  Model := Chart.GetChartModel;
  Assert.AreEqual(2, Model.AxisGroupCount);
  Assert.AreEqual(4, Model.AxisCount);
  Assert.AreEqual(0, Model.GetAxis(1).AxisGroup);
  Assert.AreEqual(1, Model.GetAxis(2).AxisGroup);
finally
  Model.Free;
  Chart.Free;
end;

如果你自己合成记录,还需要注意两点。TXLSCustomChart.AddData(RecID, Len, nil)Len 非零时会解引用载荷,因此 Begin($1033)和 End($1034)标记必须以零长度添加,不能使用 nil blob 和过期长度。合成序列只能证明解码器,不能证明 Excel 接受你的输出——写入侧根据 ABNF 组织,然后通过 GetChartModel 往返检查,断言每个轴的 0、0、1、1 标记以及每个序列的 0、1 图表组,并以无次坐标轴路径逐字节不变作为安全网。这正是HotXLS Delphi 图表、图像和绘图支持其余部分采用的保守姿态:按记录所说的内容解码,拒绝猜测未在规范中读到的二进制布局。写入侧完成后,完整 Delphi 套件在 Win32 和 Win64 上均通过了 1650 项中的 1650 项

Chart3d 场景参数与 fAuto 陷阱

当你超出默认图表时,有两个相邻细节会造成麻烦。第一是 Chart3d($103A,[MS-XLS] 2.4.46),这是 3D 变体在图表组内发出的固定 14 字节载荷:anRot(旋转,0 到 360)、anElev(仰角,有符号,-90 到 90)、pcDist(透视距离,0 到 100,只有设置 fPerspective 时才使用)、pcHeightpcDepth(图表宽度的百分比,5 到 500)、pcGap(0 到 500),以及一个 grbit,其中各位为 fPerspective $0001、fCluster $0002、fAutoscale $0004、f3DScaling $0010 和 f2DWalls $0020。规范还增加了记录布局不会替你执行的约束:转置条形图上的 anRotanElev 不得超过 44,饼图上的 anElev 不得为负

第二是 fAuto 位,它会产生“我的颜色被忽略了”的问题报告。LineFormat($1007)、AreaFormat($100A)和 MarkerFormat($1009)都在 grbit 的第 0 位携带 fAuto;设置该位时,Excel 会应用自动样式,并把旁边的显式 RGB 值、线型、标记形状和标记大小视为装饰。任何写入自定义序列样式的发射器都必须清除第 0 位;默认发射器则有意保留它,让 Excel 选择调色板。如果你是在编辑现有工作簿而不是构建新工作簿,保留规则又不同,详见编辑 Excel 图表而不丢失保留的 ChartML

次坐标轴组、SerToCrt 绑定以及这里展示的带类型图表模型,都属于面向 Delphi 和 C++Builder 的 HotXLS Delphi 电子表格组件,无需安装 Excel 即可读写 BIFF8 图表;产品页提供完整的图表记录参考和 AddChartSheet 重载列表