技术文章

Delphi 中 HotXLS 图表指纹时机与锚点偏移

HotXLS Delphi Component 只有在两个条件同时成立时,才能把未修改的 Excel 图表逐字节重放:图表是通过 worksheet drawing 关系找到的,而不是靠猜出来的部件名;64 位模型指纹是在图表模型解析完成之后才抓取的。2.382.0 修好了第一个条件,2.382.3 修好了第二个,并开始往返非零的 xdr:colOff 和 xdr:rowOff 锚点偏移——此前 drawing 写入器一直把这两个值硬编码成零。两个缺陷都出自本地语料里的同一个用例 two-charts.xlsx:先是结构断言发现两个图表部件变成了三个,接着对每个 xl/charts/chartN.xml 做字节比较,显示没人动过的图表仍在被重写——而这两个问题既不抛异常,也不让 Excel 抱怨,这正是它们能活这么久的原因

为什么两个图表的工作簿会带着三个图表部件回来?

因为加载器有一个靠猜的兜底分支。当一个 worksheet 在它的 .rels 部件里没有 drawing 关系时,旧代码就假定 drawing 住在约定名 xl/drawings/drawing{i+1}.xml 下(i 是工作表位置),只要归档里存在这个部件就把它挂上去。在 two-charts.xlsx 里,第一个工作表既没有 drawing,也完全没有 .rels 部件,而 xl/drawings/drawing1.xml 确实存在——它属于第二个工作表,后者通过 Target="../drawings/drawing1.xml" 找到它。于是 Sheet 1 继承了它从未引用过的图表,chart1.xml 被解析了两次,保存时写出的工作簿带着三个图表部件而不是两个

HotXLS 在 two-charts 示例里如何解析 worksheet drawing:Sheet1 既没有 drawing 关系也没有 rels 部件,而 Sheet2 通过 ParPartTargets 找到 xl/drawings/drawing1.xml;2.382.0 之前的兜底分支按工作表位置猜测那个约定名,结果 chart1.xml 被解析两次、保存写出三个图表部件,直到修复后只通过 XlsxRtDrawing 加载 drawing
Sheet1 从未引用过任何图表,所以关系图才是 drawing 目标唯一可靠的来源,而一个靠猜的约定名把两个图表的工作簿变成了三个部件的保存结果

HotXLS v2.382.0 里的修复把猜测整个删掉了。worksheet drawing 现在只通过 ParPartTargets[i].Values[XlsxRtDrawing] 加载——也就是该工作表上为 drawing 关系类型记录下来的目标;没有这种关系的工作表就完全不加载 drawing。这正是格式本身要求的行为:worksheet 里的 <drawing r:id="…"/> 元素(ECMA-376 Part 1 §18.3.1.36)是工作表与它的 drawing 之间唯一的链接,而 OPC 包里的部件名除了关系图赋予的含义之外不带任何意义。Excel 写出的归档碰巧使用那些约定名,这正是这条捷径能蒙混过关这么久的原因;HotXLS 中的 OPC 关系解析那篇走查解释了为什么猜测部件名永远不安全,哪怕猜的时候通常都对

// v2.382.0 之前:缺少 drawing 关系时会退回猜测
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if drawingName = '' then
  drawingName := 'xl/drawings/drawing' + IntToStr(i + 1) + '.xml';
if zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);   // 可能属于另一个工作表

// 从 v2.382.0 起:只看关系,没有就不加载
drawingName := ParPartTargets[i].Values[XlsxRtDrawing];
if (drawingName <> '') and zip.Exists(drawingName) then
  LoadDrawing(zip, drawingName);

图表指纹到底保证了什么?

指纹按图表逐个决定:保存时可以复制原始部件,还是必须重新生成。导入时,只要在 Open 之前启用 PreserveUnsupportedParts,HotXLS 就把每个图表部件的原始 UTF-8 字节留在 FRawChartXml 里,用 BuildChartKnownXml 构建类型化模型自己的序列化,并把这个序列化的长度存进 FRawChartModelLength、把它的哈希存进 FRawChartModelHash。哈希是对生成 XML 的 UTF-16 码元做 FNV-1a,用标准的 64 位 offset basis 14695981039346656037 和质数 1099511628211。保存时 XlsxChartRawModelUnchanged 重建 known XML 并比较长度与哈希;一致就说明类型化模型和导入时完全相同,应用能改动的东西一个都没变

HotXLS 在导入时抓取图表指纹:原始 UTF-8 字节留在 FRawChartXml 里,BuildChartKnownXml 产出 FRawChartModelLength 和一个 FNV-1a 哈希;保存时 XlsxChartRawModelUnchanged 重建并比较这两个值,一致就重放原始字节或直接复制压缩条目,不一致则落到 XlsxMergeChartXml
指纹的可信度只取决于抓取它的那一刻,而在每个恢复 pass 跑完之前就抓取,注定得到一个再也匹配不上完成模型的哈希
function XlsxChartRawModelUnchanged(Chart: TXLSXChart;
  const KnownXml: WideString): Boolean;
begin
  Result := (Chart <> nil) and (Chart.FRawChartXml <> '') and
    (Length(KnownXml) = Chart.FRawChartModelLength) and
    (XlsxChartModelHash(KnownXml) = Chart.FRawChartModelHash);
end;

function BuildChartXmlFromKnown(Chart: TXLSXChart;
  const KnownXml: WideString): WideString;
begin
  if Chart.FRawChartXml = '' then
    Result := KnownXml                                  // 什么都没保留
  else if XlsxChartRawModelUnchanged(Chart, KnownXml) then
    Result := XlsxDecodeChartUtf8(Chart.FRawChartXml)   // 逐字重放
  else
    Result := XlsxMergeChartXml(
      XlsxDecodeChartUtf8(Chart.FRawChartXml), KnownXml); // 结构化合并
end;

XLSX 写入器比 BuildChartXmlFromKnown 还多走一步。当模型没变而 StrictOOXML 关闭时,它会先尝试把压缩条目从源归档直接复制到输出的图表新部件名下,这样字节连解码再重新 deflate 都不用。只有这次复制做不到,它才退到解码或合并那条路。机制本身——长度加哈希,相等就重放、不等就合并——在在不丢失 ChartML 的前提下编辑 Excel 图表那篇笔记里有完整描述。本文要讲的是它如何悄无声息地失效了

为什么每个图表最终还是走了合并路径?

因为指纹抓早了一次调用。HotXLS 里的图表解析是对图表部件的一遍 SAX 扫描,随后跟着一组恢复 pass,从原始文本里捞出 SAX 处理器没有直接建模的细节:XlsxChartParseSeriesFlags 读取每个 <c:ser> 块的 <c:smooth> 标志,以及 marker fill 和 marker line 的 srgbClr 值,然后恢复类目轴和数值轴的 axis crossing mode 与主次刻度样式。在 2.382.3 之前,ParseChartXml 末尾的顺序是:先给坐标轴分组分类,构建 known XML,抓取长度与哈希,最后才跑 XlsxChartParseSeriesFlags。于是指纹描述的那个模型还缺 smooth 标志、marker 颜色和刻度。保存时 BuildChartKnownXml 却是对着已经完成的模型跑的,此时它会输出 <c:smooth val="1"/> 和恢复出来的 marker 颜色。XML 更长、哈希不同,XlsxChartRawModelUnchanged 返回 False,图表就走 XlsxMergeChartXml。对有人编辑过的图表来说合并是正确的,但它不是保持字节的操作:它会重新序列化整棵树,而「series、坐标轴和 plot group 由类型化模型说了算」的归属规则意味着重新生成的节点会替换掉原来的。语料运行里肉眼可见的结果,是没人编辑过的图表出现系列颜色漂移——每个保留工作簿里的每个图表、每次保存都如此,而且任何地方都没有诊断信息

修复只是一处重排序:XlsxChartParseSeriesFlags 现在在建 known XML 之前运行,于是指纹描述的就是应用第一次看到它时的那个模型。这个教训不限于图表。变更检测指纹的可信度只取决于抓取时机,而安全的时机是所有可能改动模型的 pass 都跑完之后。HotXLS 里同样这两个值还有第二个抓取点——成功保存之后针对输出文件重新建立的基线——那个点一直都是在完整解析过的模型上运行的;导入时那个点才是异类

锚点偏移去哪了?

进了一个字面意义的零。drawing 部件里的 twoCellAnchor 把图表钉在两个单元格之间,每个角都带一个单元格索引加上该单元格内部的偏移:from(ECMA-376 Part 1 §20.5.2.5)和 to(§20.5.2.32)各自持有 col、colOff(§20.5.2.4)、row 和 rowOff。偏移单位是 EMU(English Metric Units),每英寸 914400;只要图表是用鼠标摆放或缩放过,Excel 就会写出非零值,而大多数图表都是这么来的。two-charts.xlsx 里的第一个图表从 row 0 开始,rowOff 为 19049,结束于 column 8、row 15,colOff 为 247650、rowOff 为 66674——差不多是最后一列往里四分之一英寸。HotXLS 的 drawing 解析器一直在读这四个值——图片那条代码路径就在用——但图表写入器给每个角都输出 <xdr:colOff>0</xdr:colOff> 和 <xdr:rowOff>0</xdr:rowOff>,保存时把每个图表都吸附到单元格网格上

HotXLS 示例中第一个图表的 xdr:twoCellAnchor 角点解剖:from 持有 col 0 和 rowOff 19049,to 持有 col 8、colOff 247650 和 rowOff 66674,单位是每英寸 914400 的 EMU;在 FFromColOff、FToColOff 及其同类字段开始重放导入值之前,那个输出零偏移的写入器一直把图表吸附到网格上
锚点住在 drawing 部件而不是 chart 部件里,所以这个修复与指纹修复彼此独立,但两者都得上线,工作簿才真正算完成了往返
// 从 v2.382.3 起,锚点写入器重放导入的 EMU 偏移
Result := '<xdr:twoCellAnchor' + EditAsAttr + '><xdr:from><xdr:col>' +
  IntToStr(Chart.FromCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FFromColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.FromRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FFromRowOff) + '</xdr:rowOff></xdr:from>' +
  '<xdr:to><xdr:col>' + IntToStr(Chart.ToCol - 1) + '</xdr:col><xdr:colOff>' +
  IntToStr(Chart.FToColOff) + '</xdr:colOff>' +
  '<xdr:row>' + IntToStr(Chart.ToRow - 1) + '</xdr:row>' +
  '<xdr:rowOff>' + IntToStr(Chart.FToRowOff) + '</xdr:rowOff></xdr:to>' + ...

TXLSXChart 现在带着 FFromColOff、FFromRowOff、FToColOff 和 FToRowOff,由 drawing 解析器填充,并在图表被赋值时随其他锚点状态一起复制过去。它们刻意保持私有:公开的锚点接口仍然是四个单元格坐标 FromRow、FromCol、ToRow 和 ToCol,用 Delphi 代码新建的图表照旧落在单元格边界上。偏移存在的意义是让往返忠实,而不是把亚单元格定位当成一项功能暴露出去。注意这个修复与指纹无关:锚点住在 drawing 部件而不是 chart 部件,所以即使某张图表的 ChartML 重放得完美无缺,少了它也照样会跳到网格线上。这些 EMU 值背后的单位换算,在 HotXLS 图片几何与 EMU 缩放那篇笔记里有讲

怎么证明图表原样完成了往返?

靠比较字节,而不是拿 Excel 打开结果。Excel 在加载时修复和归一化的东西太多,一个已经漂移的图表看起来一切正常,直到分析师发现 marker 颜色变了。抓住这两个缺陷的语料测试,在「打开—不编辑—保存」之后做三件事:遍历 worksheet、drawing 和 chart 关系,只要出现重复、孤儿或悬空的图表引用就失败;比较原始文件与输出之间图表类型、系列公式和锚点几何的签名;对 two-charts.xlsx 则从两个归档里读出每个 xl/charts/chartN.xml,要求字节完全一致。同样的检查用 RTL 的 TZipFile 在 Delphi 里很容易写出来

uses System.Zip, System.SysUtils;

function ChartPartsIdentical(const Original, Resaved: string): Boolean;
var
  Src, Dst: TZipFile;
  Name: string;
  A, B: TBytes;
begin
  Result := True;
  Src := TZipFile.Create;
  Dst := TZipFile.Create;
  try
    Src.Open(Original, zmRead);
    Dst.Open(Resaved, zmRead);
    for Name in Src.FileNames do
      if Name.StartsWith('xl/charts/chart') and Name.EndsWith('.xml') then
      begin
        Src.Read(Name, A);
        Dst.Read(Name, B);   // 部件消失时会抛异常
        if (Length(A) <> Length(B)) or
           ((Length(A) > 0) and not CompareMem(@A[0], @B[0], Length(A))) then
        begin
          Writeln('changed: ', Name);
          Result := False;
        end;
      end;
  finally
    Dst.Free;
    Src.Free;
  end;
end;

有三个条件让这个比较有意义,而每一个被忘掉都会安静地失效。PreserveUnsupportedParts 必须在 Open 之前为 True,否则不会抓到任何原始字节,每个图表都从模型重建。StrictOOXML 必须为 False,因为严格模式按设计强制重新生成。还有,应用在打开与保存之间不能碰图表——读属性没问题,但任何改动类型化模型的 setter 都会翻转指纹,把图表送去合并路径;那是正确行为,只是不是这个测试要测的东西。保存时图表部件还会按工作簿范围的计数器重新编号,所以工作表顺序或图表顺序变过的工作簿,会把相同的字节放到不同的 chartN.xml 名字下;语料检查器因此跟踪关系而不是名字

两个修复分别在 HotXLS 2.382.0 和 2.382.3 交付,并在 Win32 与 Win64 上对着本地语料验证过;重新保存的图表样本还经过一套独立的办公软件渲染成 PDF,与原始文件逐页对比。HotXLS 用原生 Delphi 和 C++Builder 代码读写 XLSX 图表,不需要安装 Excel——正是这一点让这种级别的保真度成为库的责任。HotXLS Delphi 电子表格组件页面有功能列表和试用版下载