HotXLS 可以把图表直接放在工作表上,锚定到某个单元格区域,而不是放到一张专门的图表工作表里。用 BIFF8 的术语说,就是写一个带类型 5 OBJ 记录的绘图形状,并把图表子流停在 sheet 记录流的末尾——这正是 Excel 自己产出的布局,也正是读取器期望找到它的位置
这个区别对任何生成运营报表的人都重要。图表工作表适合安放单个头版视觉。一份月度区域拆解报表,要的是图表就待在它所总结的数字旁边——同一张表上,尺寸贴合它所属的那块单元格区域,读者滚一次屏就够了,而不是切换标签页然后丢了上下文
读取早已就位,写入一直缺席
这种不对称值得点破,因为它决定了这活怎么干。HotXLS 本来就能读内嵌图表:当 worksheet 记录流里出现一个标记为 chart 子流的 BOF 时,解析器切换上下文,收集图表记录,在收尾的 EOF 处把它们交还给 OBJ 记录引入的那个绘图形状。这条路径被库打开过的每一份 Excel 制作的工作簿都走过
缺的是写入侧,而这件事有用的副作用是:新的写入器有一份精确的规格可以瞄准——产出既有读取器已经会重新挂接的字节布局。对一个二进制格式功能来说,没有比「一个你无权修改的、独立写成的读取器」更好的验收标准了
一个内嵌图表由什么构成
三块东西必须互相咬合。绘图层贡献一个宿主控件形状,对象层贡献一个 OBJ 记录——其 common object data 声明对象类型 5——记录流贡献图表子流本身。OBJ 记录上的选项标志用的就是 Excel 给图表框写的那套:positioned、locked、automatic line 和 automatic fill,内嵌图表被用户点击时的表现因此和原生图表一样
锚点值得一提,因为它是差一错误的常见来源。HotXLS API 接受 1 基的行列号,与库的其他部分一致,而写进文件的 client anchor 是 0 基的。转换发生在 AddChartObject 内部,调用方可以继续待在他们处处使用的坐标系里;但任何拿十六进制转储对照自己调用参数的人,都得记住自己读的是这条边界的哪一侧
var
Book: TXLSWorkbook;
Sheet: TXLSWorksheet;
Series: array[0..1] of TXLSChartSeriesInfo;
begin
Book := TXLSWorkbook.Create(nil);
try
Book.LoadFromFile('regional-sales.xls');
Sheet := Book.Sheets[0];
FillChar(Series, SizeOf(Series), 0);
Series[0].Name := 'Actual';
Series[0].Categories := 'Data!$A$2:$A$13';
Series[0].Values := 'Data!$B$2:$B$13';
Series[0].DataLabels.ShowValue := True;
Series[0].HasDataLabels := True;
Series[1].Name := 'Target';
Series[1].Categories := 'Data!$A$2:$A$13';
Series[1].Values := 'Data!$C$2:$C$13';
Series[1].SecondaryAxis := True;
// 锚定到本表的 E2:M20,行列为 1 基
Sheet.AddChartObject(xlsChartTypeColumn, 'Regional sales',
'Month', 'Amount', Series, 2, 5, 20, 13);
Book.SaveToFile('regional-sales-charted.xls');
finally
Book.Free;
end;
end;
系列数组上的 FillChar 不是装饰。TXLSChartSeriesInfo 携带好几个可选子记录——数据标签、按系列样式、趋势线、误差线——每个都由一个布尔开关把守,栈上一个部分初始化的记录会交给发射器一堆没人设置过的标志。先把数组清零,再设置你真正想设的字段
内嵌路径接受哪些系列引用?
同一工作簿内的普通 A1 风格区域——这个限制是刻意的,不是疏忽。每一条引用都对照工作簿的工作表列表解析,转换成图表记录需要的外部引用索引。命名区域或外部工作簿引用会回落成一个带零长已解析表达式的占位符,图表照常干净写出,只是那条系列在你把它指向某个区域之前没有数据源
理由是一笔直白的工程账。完整的引用编译路径在图表工作表路线上存在,包在Worksheet集合层里;把它干净地抽出来,意味着为一个实践中并不常见的情况复制上百行解析逻辑。内嵌图表几乎总是画自己那张表或相邻数据表上的单元格。命名引用和外部引用在图表工作表路径上经由 AddChartSheet 覆盖,所以没有任何东西不可用,只是入口不同
系列模型的其他一切在两条路线上完全一致。副轴绑定、按系列的线条、填充和标记样式、趋势线、误差线、数据标签,都是 TXLSChartSeriesInfo 的一部分,发射方式也相同,于是一个图表定义可以在内嵌对象和图表工作表之间搬家,只换调用不动定义。副轴标志背后的轴组机制在 BIFF 写出时的副轴组一文中有覆盖
为什么图表标题读出来只有两个字符?
因为在要字节数的地方传了字符数,而 BIFF Unicode 字符串让这个错误特别好写、特别难看见。一个短的 BIFF Unicode 字符串以字符数加一个标志字节开头,标志字节携带 high-byte 位,说明负载是每字符一字节还是两字节。拿着字符数当字节长度去读 16 位负载,你恰好得到一半的字符串:名为 Sales 的系列读回来是 Sa,图表标题同样被截断,因为标题和系列标签走同一条解码路径
这个缺陷值得注意的是,它在同一族记录里复发了三次:一次在趋势线名,一次在数据透视图表名,一次在图表标题。每次看起来都像新功能里的新 bug。三处其实是同一个漏乘。最终把它关掉的规则是机械式的,应该不假思索地执行:读这类字符串时,先查 high-byte 标志,在碰缓冲区之前把字符数乘上负载宽度。记录级细节见 解码 XLUnicodeString 字符数与 high-byte 标志
// 内嵌图表与图像、形状共享绘图层,所以表上已有的绘图
// 对象会保留。AddChartObject 返回的
// 是新建对象的索引
var
ObjIndex: Integer;
begin
ObjIndex := Sheet.AddChartObject(xlsChartTypeLine, 'Trend',
'Week', 'Units', Series, 2, 8, 18, 16);
if ObjIndex < 0 then
raise Exception.Create('chart object was not created');
end;
内嵌图表与其他方案相比的位置
共有三条路线,回答的是三个不同的问题。内嵌图表对象属于数据旁边的工作表,是大多数报表想要的。图表工作表适合单个演示视觉,并给你完整的引用编译路径。从已加载文件里原样保留一张现成图表,则是工作簿出自 Excel、带着没人想让库重新解释的格式时的正确答案;这种直通行为在 保留 ChartML 与组合图表一文中有描述
因为内嵌图表搭的是绘图层,它与同一张表上的图像和形状共存,而不是取而代之,该层的通用模型见 HotXLS 中的图表、图像与绘图。三条路线都随 HotXLS Delphi spreadsheet component 提供,所以选择取决于报表应该长什么样,而不是库能表达什么
方法论上的那一点才最值得留住。当一个二进制格式功能已有现成的读取器时,对着读取器构建写入器,而不是对着你自己对规格的解读。读取器编码了多年与真实应用程序产出的文件打交道的经验,包括规格里写得含糊的那些部分;满足读取器的写入器,满足 Excel 的概率也大得多