技术文章

HotXLS 写出通过 ECMA-376 校验的 XLSX Pivot 字段

HotXLS 写出的 XLSX pivot table 定义,其 pivotField 和 cacheField 元素能通过 ECMA-376 Part 1 §18.10 schema 的校验:轴属性使用 ST_Axis 令牌 axisRow、axisCol 和 axisPage,值区字段带 dataField="1",item 列表永不为空,cache 字段存数字型 numFmtId。从 v2.384.33 起,读取端还修正了它过去一直读错的 schema 默认值

这次清理背后的 bug 们共享一个不太体面的特点:没有一个在测试里失败过。HotXLS 写一个 pivot,HotXLS 读回来,每个字段都落在正确的轴上,往返测试套件一连绿了好几年。问题在于写入器和读取器悄悄达成了一种私人方言。Delphi 里构建的 pivot 在生成它的组件眼里一切正常,而对照 CT_PivotField 和 CT_CacheField 一查,就查出非法的枚举令牌、schema 明令禁止的空元素,以及 Excel 期待却从未得到的标志位。如果你在服务器上生成 pivot、发给要在 Excel 里打开或喂给自己解析器的人,那么唯一算数的契约就是 schema,而不是你自己的读取器碰巧原谅了什么

为什么 HotXLS 的往返从没抓到错误的轴令牌?

HotXLS 往返从未抓到错误的轴令牌,因为读取器两种拼法都收。旧的 XlsxPivotAxisAttr 发出 axis="rowAxis"、colAxis 和 pageAxis,英文里读着自然,schema 里却不存在;ST_Axis 恰好定义了四个值:axisRow、axisCol、axisPage 和 axisValues。与此同时,lxPivotXml.pas 里的 PivotAxisFromToken 对 schema 令牌和自造令牌来者不拒,于是每个自测都通过。写入器现在只发 schema 令牌,读取器则继续接受旧拼法,让早期 HotXLS 版本保存的文件仍能原样布局加载

<!-- v2.384.33 之前:非法的 ST_Axis 值,空的 CT_Items -->
<pivotField axis="rowAxis" defaultSubtotal="1"><items count="0"></items></pivotField>

<!-- v2.384.33 起 -->
<pivotField axis="axisRow" defaultSubtotal="1">
  <items count="4"><item x="0"/><item x="1"/><item x="2" h="1"/><item t="default"/></items>
</pivotField>
HotXLS 的 pivotField XML 在 v2.384.33 前后对比:自造的轴值 rowAxis 和空的 items 元素违反 CT_PivotField,直到写入器发出 axisRow 这类 ST_Axis 令牌、真实的 item 条目、保留的隐藏标志,以及 schema 接受的尾部 default 小计
宽松的读取器两种拼法都收,所以每次往返都通过,而文件在任何严格 schema 检查下都过不了——只写四个 ST_Axis 令牌,让 CT_Items 至少带一个 item

CT_PivotField 要求而旧写入器漏掉的是什么?

CT_PivotField 要求三件事,而旧的 BuildPivotTableXml 或漏掉或写错。第一,在值区聚合的字段必须在自己的定义上声明 dataField="1";写入器现在会给 DataFields 条目引用到的每个字段设置这个标志,而不只是在 <dataFields> 列表里。第二,CT_Items 需要至少一个 item,所以没有 item 的字段不再得到空的 <items count="0">,整个元素直接省略。第三,每个 item 保留自身状态:隐藏 item 是 h="1"(TXLSPivotItem.IsHidden),收起明细是 sd="0"(IsDetailHidden),这两样旧写入器每次保存都会丢

微妙的部分是尾部的小计 item。字段有 item 时,Excel 会在数据 item 之后为每个小计函数多列一个 item,类型用 ST_ItemType 标注:自动小计是 <item t="default"/>,显式小计则是 sum、countA、avg、max、min、product、count、stdDev、stdDevP、var 和 varP。HotXLS 保存时从 TXLSPivotField.Subtotals 推导这些条目,并把它们计入 items count。AddPivotTable 创建的字段以空的 Subtotals 集合起步,写出的是 defaultSubtotal="0" 且没有尾部 item,所以报表需要小计时请显式指定。注意命名陷阱:xlpsCount 映射到 countA(全部条目),xlpsCountNums 才映射到 count(仅数字)

HotXLS pivot items 列表结构:数据 item 条目之后是由 TXLSPivotField.Subtotals 推导的尾部小计 item(如 t=default 和 t=avg),并计入 items count;xlpsCount 映射 countA、xlpsCountNums 映射 count 的命名陷阱也一并标出
AddPivotTable 创建的字段以空的 Subtotals 集合起步,写出 defaultSubtotal=0 且没有尾部 item——显式指定你要的函数,写入器就会为每个函数推导一个 item 计入总数
uses
  lxHandleX, lxPivot;

var
  Book  : TXLSXWorkbook;
  Sheet : TXLSXWorksheet;
  Pivot : TXLSPivotTable;
  Region: TXLSPivotField;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('orders.xlsx');
    Sheet := Book.Sheets[1];                  // 从 1 起算,与 XLS 引擎一致
    Pivot := Sheet.AddPivotTable('Data!$A$1:$D$800', 3, 6, 'RegionTotals');
    if Pivot = nil then
      raise Exception.Create('Bad source range or anchor');

    Region := Pivot.AddRowField('Region');    // 无此字段时为 nil
    if Region <> nil then
      Region.Subtotals := [xlpsDefault, xlpsAverage];  // -> t="default", t="avg"
    Pivot.AddColumnField('Quarter');
    Pivot.AddDataFieldByName('Revenue', xlpaSum);      // 给 Revenue 打上 dataField="1"

    Book.SaveAs('orders-pivot.xlsx');
  finally
    Book.Free;
  end;
end;

HotXLS 现在怎么读小计 item 和 schema 默认值?

HotXLS 读取端现在会跳过任何 t 属性存在且不为 data 的 item,因为小计、总计和空白条目不带缓存索引。v2.384.34 之前,这些条目被当作普通 item 加载,CacheItemIndex 设为 -1,于是 Excel 生成的 pivot 读回来会带着指向空处的幽灵成员,任何遍历 Items 的代码都得手动把它们滤掉。既然写入器会从 Subtotals 重建尾部条目,读取器的职责就是把它们翻译回那个集合,而不是当成数据留下来

第二处读取端修复关乎缺席的属性。schema 里,CT_PivotField 的 defaultSubtotal 和 CT_SharedItems 的 containsString 都默认为 true,Excel 取默认值时会省略它们。HotXLS 过去把缺失属性读成 false,这意味着 Excel 保存的每个 pivot 在加载时都无声地丢了默认小计,纯文本缓存字段则被归类成混合型而不是字符串型。这正是轴 bug 的镜像:一个把每个属性都逐一写全的写入器永远不会走到默认值路径,只有其他生产者产出的文件才会暴露它

为什么 cache 字段上的 numFmtId="General" 非法?

numFmtId="General" 之所以非法,是因为 ST_NumFmtId 是无符号整数,不是格式名。旧的 cache 写入器在每条 cacheField 上硬编码这个字符串,借用了用户在设置单元格格式对话框里看到的那个名字。HotXLS 现在把 cache 字段的 NumberFormat 写成数字,未设置时就是 0(内建的 General 格式)。按 schema 给属性定型的严格解析器会直接拒收旧值,而这正是会演变成修复对话框的那类失败;Excel 修复提示背后的 OPC 与标记规则一文讲了那些对话框如何被触发

为什么 65535 行以下的 pivot table 会被切掉?

放在 65536 行或更下方的 XLSX pivot table 之所以被切掉,是因为共享的 pivot 模型把 FirstRow、LastRow、FirstHeaderRow、FirstDataRow 以及对应的列属性存成 Word,行位移代码还用 Min(.., High(Word)) 钳制它们。那是 BIFF8 SxView 记录的遗留——16 位在那里够用,可 XLSX 工作表有 1,048,576 行。从 v2.384.37 起,TXLSPivotTable 的这些属性改为 Integer,钳制移除,只有 BIFF8 写入器收窄取值。TXLSXWorksheet.AddPivotTable 和 AddPivotTableCopy 现在对 1..1048576 × 1..16384 之外的锚点,或范围会越出网格的副本,返回 nil

HotXLS 的 pivot 锚点在第 70001 行撞上 16 位上限:FirstRow 和 LastRow 存成 Word 并用 Min 对 65535 的 High(Word) 钳制,这条线及以下的 pivot 都被切掉,直到 v2.384.37 把模型改为 Integer 字段、网格外返回 nil
Word 字段是 BIFF8 SxView 的遗留物,而这个格式的工作表有 1048576 行——65536 行之外的锚点过去会回绕进 16 位范围,保存时把 pivot 丢掉
var
  Pivot: TXLSPivotTable;
  Check: TXLSXWorkbook;
begin
  // 第 70001 行过去会回绕进 16 位范围;现在能完好走完保存与加载
  Pivot := Sheet.AddPivotTable('Data!$A$1:$D$800', 70001, 1, 'LateTotals');
  if Pivot = nil then
    Exit;  // 锚点在工作表外,或源区域无法解析
  Pivot.AddRowField('Region');
  Pivot.AddDataFieldByName('Revenue', xlpaSum);
  Book.SaveAs('late.xlsx');

  Check := TXLSXWorkbook.Create;
  try
    Check.Open('late.xlsx');
    Pivot := Check.Sheets[1].PivotTables.FindByName('LateTotals');
    Assert((Pivot <> nil) and (Pivot.FirstRow = 70001));
  finally
    Check.Free;
  end;
end;

经典 XLS 引擎在 v2.384.38 得到了对应修复。它的模型过去存 0 起算的原始 SxView 和 DConRef 值,把 AddPivotTable 锚点原样放行,而文档、demo 和 XLSX 引擎用的都是 Cells[Row, Col] 这类从 1 起算的单元格。现在两个引擎都在模型里保存从 1 起算的位置,BIFF8 读取端加 1、写入端在记录边界减 1,所以过去锚在 (0, 0) 的代码必须改到 (1, 1),因为经典 AddPivotTable 现在对 1..65536 × 1..256 之外的锚点返回 nil;新调用写出的字节与旧调用完全一致。记录布局本身未变,详见经典 .xls pivot table 背后的 BIFF8 SX 记录

对着 schema 校验,而不是对着自己的读取器

这条教训在 pivot 之外同样成立:宽松的读取器会掩盖写入端的违规,所以经过你自己代码的往返证明的是一致性,不是正确性。这里的每个 bug 都活了下来,因为宽容的一侧和出错的一侧住在同一个库里。真正能抓住这类缺陷的检查是:对生成部件做 schema 校验;把 Excel 产出的、在默认值处省略属性的文件喂给你的读取器;以及用固定确切令牌而非解析结果的测试夹具。通过 API 构建的 pivot——包括用计算字段构建并刷新 XLSX pivot table里展示的计算字段、计算项和百分比布局——不用改一行代码就能拿到修正后的 XML;从 Excel 文件加载的 pivot 则会继续重放原始部件,直到你修改它们

以上修复全部随当前的HotXLS Delphi 电子表格组件发布,它在 Delphi 和 C++Builder 上读写 XLS、XLSX 和 pivot table,机器上不需要 Excel 或 COM automation