技术文章

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

一份带两个图表的工作簿经历一次什么都不编辑的打开再保存,出口处可能出现三种不同的东西。一种输出有三个图表部件,而源里只有两个。另一种仍是两个,但字节比较显示每个 xl/charts/chartN.xml 都被重写过,系列颜色也漂移了。第三种保住了图表 XML,可每张图表都跳了几像素、落到了单元格网格线上。Excel 打开这三种都不会弹修复提示。每个症状都来自加载与保存路径上的一个决定:工作表如何找到它的绘图、图表的变更指纹在什么时刻采集,以及绘图部件内部的锚点偏移是否写回

工作表靠自己的关系找到绘图

三个部件的症状来自靠猜部件名的加载器。XLSX 包里,工作表用 <drawing r:id="…"/> 元素指向自己的绘图(ECMA-376 Part 1 §18.3.1.36),r:id 经该表的 .rels 部件解析到 ../drawings/drawing1.xml 这样的目标。Excel 恰好按可预测的方式给绘图编号,所以手写的阅读器常常长出一个回退:关系缺失时,对位置 n 的表试一下 xl/drawings/drawing{n}.xml,归档里有就用

// 手写 XLSX 阅读器里常见的捷径(别把它带上线)
function GuessDrawingPart(Zip: TZipFile; SheetPos: Integer;
  const RelTarget: string): string;
begin
  Result := RelTarget;   // 从工作表的 .rels 解析而来,缺失时为 ''
  if Result = '' then
  begin
    Result := 'xl/drawings/drawing' + IntToStr(SheetPos + 1) + '.xml';
    if Zip.IndexOf(Result) < 0 then
      Result := '';
  end;
end;

这个猜测在一张没有图表的表排在前面时失效。一个工作簿的第一张表既没有绘图也没有任何 .rels 部件,而第二张表经 Target="../drawings/drawing1.xml" 到达 xl/drawings/drawing1.xml,此时回退给表 1 递上一个它从未引用过的绘图。chart1.xml 被解析两遍、每表一遍,保存时写出三个图表部件。OPC 包里的部件名除关系图赋予的意义外别无含义,这正是 HotXLS 的 OPC 关系解析一文的核心论证

一份两图表工作簿里工作表如何找到自己的绘图:Sheet1 没有绘图关系也没有 rels 部件,Sheet2 经自己的绘图关系到达 xl/drawings/drawing1.xml,于是从表位置猜约定名的加载器会解析 chart1.xml 两遍并存出三个图表部件,这正是 HotXLS 只经工作表的绘图关系加载绘图的原因
Sheet1 从未引用过图表,所以关系图是绘图目标唯一安全的来源;猜一个约定名会把两图表的工作簿变成三部件的保存

HotXLS Delphi Component 采取严格读法。工作表的绘图只从该表绘图关系的目标加载,没有这种关系的表就没有绘图,无论 xl/drawings/ 里碰巧躺着什么文件。无人引用的绘图部件永远不会被挂到某张表上

什么决定未被改动的图表是复制还是重建?

由一个按图表计算的指纹决定。当 Open 时 PreserveUnsupportedParts 为 True(默认就是 True),HotXLS 把每个图表部件的原始字节保存在解析出的图表模型旁边。随后它用自己的图表写出器序列化该模型,并为这份输出记录两个数字:长度,以及对其 UTF-16 码元做 64 位 FNV-1a 哈希的结果,用标准偏移基 14695981039346656037 和质数 1099511628211。TXLSXChart.HasPreservedXml 报告某张图表是否带着这些原始字节

保存时图表模型再序列化一次,两个数字都被比较。长度相等且哈希相等意味着模型与导入时一模一样,应用能改的东西什么都没改。这种情况下,在 StrictOOXML 关闭时,写出器把压缩条目从源归档直接复制到输出里图表的新部件名下,字节连解码和重新压缩都不经过。两个数字有任何一个不同,就改为把原始 XML 与模型合并。改动误差线或趋势线的线条格式同样算编辑,哪怕那种格式并不在序列化模型里

HotXLS 在导入时采集图表指纹:保留图表部件的原始字节,并对自己序列化图表模型的长度和 64 位 FNV-1a 哈希做记录;保存时重建并比较两个值,匹配则重放原始字节或复制压缩条目,不匹配则把原始 XML 与模型合并
指纹的好坏取决于采集的时机,在每一轮恢复遍历结束之前采集,得到的哈希注定永远与完成的模型不匹配

对被人编辑过的图表,合并是正确的操作。它保留模型未覆盖的原始 XML 部分,让模型在系列、轴和绘图组上获胜,再重新序列化结果。它做不到的是复现原始字节。不丢 ChartML 地编辑 Excel 图表的笔记讲合并保留了什么;本文讲的是未被编辑的图表在哪些条件下永远不会走到那一步

uses System.SysUtils, lxHandleX;

procedure ResaveUntouched(const Src, Dst: string);
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  S, C: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.PreserveUnsupportedParts := True;  // 默认值;必须在 Open 之前成立
    Book.StrictOOXML := False;              // Strict 输出会重新生成每张图表
    if Book.Open(Src) <> 1 then
      raise Exception.Create('Cannot open ' + Src);
    for S := 1 to Book.Sheets.Count do      // Sheets[] 从 1 起
    begin
      Sheet := Book.Sheets[S];
      for C := 0 to Sheet.Charts.Count - 1 do  // Charts[] 从 0 起
        Writeln(Sheet.Name, ' / ', Sheet.Charts[C].Name, ': original kept = ',
          BoolToStr(Sheet.Charts[C].HasPreservedXml, True));
    end;
    if Book.SaveAs(Dst) <> 1 then
      raise Exception.Create('Cannot save ' + Dst);
  finally
    Book.Free;
  end;
end;

想刻意从模型重新生成某张图表时,比如要剥掉源文件带的格式,TXLSXChart.ClearPreservedXml 丢掉存储的字节,那张图表就只从模型写出

为什么每张图表反正都走了合并路径?

因为早采一拍的指纹永远匹配不上。图表 XML 很少能一遍读完。流式解析先建出系列、轴和绘图组,然后一组后续遍历再从原始图表文本里读那些等系列存在之后才好恢复的细节:每个 <c:ser> 上的 <c:smooth> 标志、标记填充与标记线的 srgbClr 值、轴交叉模式、主次刻度线样式。流式遍历运行时系列集合根本还不存在,所以这些遍历只能排在它后面

假设指纹在流式遍历结束时、后续遍历运行之前采集。它描述的就是一个没有 smooth 标志、没有标记颜色、刻度线是默认值的模型。保存时序列化器对完成的模型运行,产出 <c:smooth val="1"/> 加上恢复出的颜色。XML 更长、哈希不同,每份被保留的工作簿里的每张图表每次保存都走合并。对带平滑系列和自定义标记的折线图,重新生成的 <c:ser> 节点替换原件,系列颜色的漂移正是从这里来的

HotXLS 只在每一轮恢复遍历都结束之后才采集指纹,所以哈希描述的是应用第一次见到的那个模型。保存成功后同样的两个数字会从输出文件重新建立,第二次采集同样从完全解析的模型出发。变更检测哈希必须在还能改动模型的最后一遍遍历之后取,取早了得到的检查每次保存都失败

锚点偏移住在绘图部件里

吸附网格的症状与图表 XML 无关。图表坐在工作表的哪里,记录在绘图部件里,常见形式是 xdr:twoCellAnchor(ECMA-376 Part 1 §20.5):一个 from 角和一个 to 角,各带 col、colOff、row 和 rowOff。偏移以 English Metric Units 计,每英寸 914400、每点 12700,凡是用鼠标拖动或缩放过的图表,Excel 都写出非零值。一个文件声明 from 角在第 0 列第 0 行、rowOff 19049,to 角在第 8 列第 15 行、colOff 247650、rowOff 66674,则这张图表从第 1 行顶部下方 1.5 点开始,末端伸进 I 列略多于四分之一英寸

示例图表 xdr:twoCellAnchor 各角的解剖:from 含 col 0 和 rowOff 19049,to 含 col 8、colOff 247650 和 rowOff 66674,EMU 每英寸 914400;由于写零偏移的写出器会把图表吸附到网格,HotXLS 在保存时把导入的偏移写回
锚点住在绘图部件而不是图表部件里,所以偏移重放与指纹互不相干;忠实的往返两者都要

读出这四个数字、却给每个角都写 <xdr:colOff>0</xdr:colOff> 的写出器,会把每张图表挪到网格线上。图表部件可以逐字节重放,图表照样会动,因为两者住在不同的部件里。HotXLS 保留加载时的锚点种类(twoCellAnchor、oneCellAnchor 或 absoluteAnchor,以 PhysicalAnchorKind 暴露)连同其 EMU 值,并原样写回。偏移可经 FromOffsetXEMU、FromOffsetYEMU、ToOffsetXEMU 和 ToOffsetYEMU 读取,背后的单位换算在HotXLS 图像几何与 EMU 缩放笔记里有完整推演

procedure NudgeChart(Chart: TXLSXChart);
const
  EmuPerPoint = 12700;
begin
  if Chart.PhysicalAnchorKind <> xlsxAnchorTwoCell then
    Exit;
  Writeln(Format('from R%dC%d +(%d, %d) EMU  to R%dC%d +(%d, %d) EMU',
    [Chart.FromRow, Chart.FromCol, Chart.FromOffsetXEMU, Chart.FromOffsetYEMU,
     Chart.ToRow, Chart.ToCol, Chart.ToOffsetXEMU, Chart.ToOffsetYEMU]));
  // 一次调用替换整个框架:行与列从 1 起,
  // 偏移是单元格内的 EMU,最后一个参数是 editAs 行为
  Chart.SetTwoCellAnchor(Chart.FromRow, Chart.FromCol, Chart.ToRow, Chart.ToCol,
    Chart.FromOffsetXEMU + 6 * EmuPerPoint, Chart.FromOffsetYEMU,
    Chart.ToOffsetXEMU + 6 * EmuPerPoint, Chart.ToOffsetYEMU, Chart.AnchorType);
end;

意图是移动或缩放时用 SetTwoCellAnchor。在双单元格图表上给 FromRow、FromCol、ToRow 或 ToCol 赋值只改那一个单元格下标、偏移原地不动——小幅调整时这是对的行为,从头放置图表时就是错的。SetTwoCellAnchor 把框架当作整体校验,终点角先于起点或偏移超出 Excel 的 EMU 范围时抛 ERangeError。用 AddChart 创建的图表偏移从零开始,所以在你给它偏移之前,它恰好落在单元格边界上

用字节比较证明往返

在这里,把输出在 Excel 里打开证明不了什么。Excel 加载时归一化得足够多,重新序列化的图表看起来一直没变,直到有人注意到标记颜色变了;几点的网格吸附在屏幕上也容易漏看。值得自动化的检查,是在一次不编辑的打开再保存之后逐字节比较图表部件,RTL 的 TZipFile 就够了

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,因为 Strict 输出设计上就重新生成图表。任何承诺未被编辑的图表在保存后存活的管线里,都让它保持关闭
  • 以 Strict OOXML 命名空间写出的源包,在 transitional 保存时会转换成 transitional 命名空间,所以它的图表字节即使什么都没改也会不同
  • Open 与 SaveAs 之间的代码可以随便读图表属性,但任何改动模型的 setter 都会改动指纹、把那张图表送进合并路径——那是正确行为,也不在本测试的度量范围
  • 图表部件在保存时按工作簿级计数器重新编号,所以工作表或图表顺序变过的工件会把相同的字节放到不同的 chartN.xml 名下;针对这种情况的检查器应跟随绘图关系而不是匹配名字

锚点需要按值比较。绘图部件是围绕它装着的图表新写出来的,所以它的字节不是该比较的对象;把两个文件重新打开、逐图对比 FromOffsetXEMU 到 ToOffsetYEMU,能抓住把 247650 写成 0 的写出器。一个遍历工作表、绘图和图表关系、对任何重复、孤儿或悬空图表引用判失败的结构检查,能在字节比较运行之前就抓住三部件的情况

这一切都跑在原生 Delphi 和 C++Builder 代码里,机器上无需安装 Excel,所以从批处理任务返回的工作簿是图表遭遇的唯一证据。许可、完整图表 API 和试用版见 HotXLS Delphi spreadsheet component 页面