技术文章

Delphi 里 ODS 数据透视表往返:XML 命名空间作用域

从 v2.382.0 起,HotXLS Delphi Excel Component 通过在打开时逐字捕获 content.xml 里的 <table:data-pilot-tables> 子树、保存时再重放,让 OpenDocument 的数据透视表挺过一次 ODS 打开与保存。从 v2.382.1 起,这段片段还会带上它祖先声明的每一个 XML 命名空间绑定,于是保存下来的透视表定义对任何使用方都是格式良好的,而不只是对 HotXLS

逼出这两处改动的 bug 来自一次严格语料运行。由 LibreOffice 6.1 开发版写出的样本 official-pivot.ods 里有一个名为 DataPilot1 的透视表,它读取 Sheet1.A2:E30,把结果落在 Sheet1.G6:J18。用 HotXLS 打开、原样保存,数一下输出里的 <table:data-pilot-table> 元素:进去一个,出来零个,Win32 和 Win64 都一样。测试里没有任何东西碰过这个透视表。第一轮探针只比较了单元格常量,通过了;真正暴露这次丢失的是结构断言——这也提醒我们,「值对得上」是往返保真度的一种很弱的定义

为什么 ODS 透视表在一次库保存后就消失了?

ODS 透视表消失,是因为 HotXLS 对 OpenDocument 数据透视表没有内存模型,而 ODS 写入器完全从模型构建 content.xml。写入器组装自动样式、每个 worksheet 一个 <table:table>、<table:content-validations>、<table:named-expressions> 和 <table:database-ranges>,每一项都由工作簿真正持有的对象生成。而一份透视表定义——ODF 1.3 Part 3 §9.6,一个 <table:data-pilot-tables> 容器,里面每个透视表一个 <table:data-pilot-table>,带着它的 table:source-cell-range、若干 table:data-pilot-field 子元素、table:target-range-address 和 table:buttons——没有任何对象可以住在里面,于是重新生成出来的那部分干脆把它省了

与 XLSX 的差别是有意为之。HotXLS 把 SpreadsheetML 的透视缓存和透视表解析成真正的模型,你可以从 Delphi 里构建、用计算字段扩展并刷新它们,所以那些东西能在保存中存活是因为它们被重写,而不是被复制。ODS 透视表是罕见得多的需求,而仅仅为了往返就去把 ODF 的 data pilot 词汇表建模一遍,会是一大堆没人编辑的代码。务实的答案就是 HotXLS 已经用在XLSX 里未知的 extLst 块上的那一条:保留你没有建模的东西,能逐字节就逐字节,不行就逐个事件

第一版基于 Pos 的捕获错在哪?

v2.382.0 的捕获是把透视表定义当普通字符串从 content.xml 里切出来的,而这段切片少了让它变得有意义的命名空间声明。实现短得名副其实——把部件解码成 WideString,用 Pos 找开标签,再找它后面的闭标签,把这一段复制进工作簿上的 FRawOdsDataPilotTablesXml:

// HotXLS v2.382.0——一个版本之后就被取代
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
  OpenTag: WideString = '<table:data-pilot-tables';
  CloseTag: WideString = '</table:data-pilot-tables>';
var
  Text: WideString;
  StartPos, ClosePos: Integer;
begin
  Result := '';
  Text := LoadPartAsWideString(Stream);   // 整个 content.xml 读进内存
  StartPos := Pos(OpenTag, Text);
  if StartPos = 0 then Exit;
  ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
  if ClosePos = 0 then Exit;
  Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;

计数断言转绿,修复也就交付了。抓住它的是同一天加的第二道更严格的检查:保存后包里的每个 XML 部件都会被喂给 HotXLS 之外一个独立的、能感知命名空间的解析器,而那个解析器以「前缀未绑定」为由拒绝了新的 content.xml。来自 LibreOffice 的透视表带着生产者的扩展属性——page field 上的 loext:ignore-selected-page="true"、每一层上的 calcext:repeat-item-labels="false"——切出来的字符串包含这些属性,却不包含绑定它们的 xmlns:loext 和 xmlns:calcext 声明。那些声明坐在源文件的 <office:document-content> 根上,一共三十五条,离这个透视表有两千个字符远

W3C Namespaces in XML 1.0 §6.1 定义的规则让这件事成为硬失败而不是外观问题:一条命名空间声明的生效范围,从它所在元素的起始标签一直到该元素的结束标签,作用域内每一个带前缀的名字都按它解析。把一棵子树从文档里切出来,你也就把它从那个作用域里切了出来。HotXLS 自己写出的 <office:document-content> 根带了十一条声明——office、table、text、style、number、fo、draw、svg、xlink、calcext、tableooo——于是 calcext: 碰巧能解析、table: 碰巧能解析,而 loext: 不能。能感知命名空间的解析器把未绑定前缀当作格式良好性违规,也就是说整个部件都读不了了,而不只是一个属性

HotXLS 里基于 Pos 的捕获在 official-pivot.ods 上漏掉了什么:透视子树的 loext 和 calcext 扩展属性都在,绑定它们的 xmlns 声明却在三十五条绑定之外的 office:document-content 根上,于是切出来的片段用到的每个前缀都处于未绑定状态,能感知命名空间的解析器直接拒绝了整个 content.xml
命名空间声明的生效范围从它的起始标签到结束标签;把子树从文档里切出来,就把它切出了那个作用域,一个属性由此变成整个部件都读不了

HotXLS 怎么把祖先的 xmlns 绑定搬到片段上?

HotXLS v2.382.1 把字符串切片换成用自家流式 TXMLReader 遍历 content.xml:维护一个命名空间绑定栈,每条绑定都标上它被声明的深度;一旦走到目标元素,就把当时仍然生效的绑定复制到片段的根元素上。reader 在 PreserveWhitespaceText 打开的状态下运行,所以文本节点回来时与原文一模一样;重建标签时用的是 TXMLReader.RawName 和 TXMLReader.Attribute[I].RawName——文件里的前缀拼法——而不是 reader 平时交给各部件解析器的规范名。下面是这个循环的核心:

HotXLS v2.382.1 如何连同命名空间作用域一起捕获 data pilot 子树:一趟流式 TXMLReader 遍历维护一个标了声明深度的 xmlns 绑定栈,在 table:data-pilot-tables 目标处由内向外走一遍,用 Seen 集合处理遮蔽,跳过元素自己声明的那些前缀,并在结束标签和空元素上同样弹出绑定
按 reader 的规范名匹配目标,让改写 table 前缀的生产者也能正常工作;而一棵永远不闭合的子树会抛异常,而不是在保存时把半截片段写回去
// Namespaces:装 'xmlns:p=uri' 的 TStringList,声明深度放在 Objects[] 里
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // 元素、文本、CDATA、注释
  if Reader.NodeType = xmlntElement then
  begin
    for I := 0 to Reader.AttributeCount - 1 do
    begin
      AttrName := Reader.Attribute[I].RawName;
      if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
        Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
          TObject(NativeInt(Depth)));
    end;
    if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
    begin
      Opening := XlsxRawXmlReaderOpenTag(Reader);   // 先剥掉结尾的 '>' 或 '/>'
      ...
      // 把生效的祖先绑定搬到片段根上。
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // 最内层的绑定胜出
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // 这里已经声明过?跳过
          Opening := Opening + ' ' + AttrName + '="' +
            XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
      end;
      ...
      CaptureDepth := Depth;
    end;
    if not Reader.IsEmptyElement then Inc(Depth);
  end
  else if Reader.NodeType = xmlntEndElement then
  begin
    Dec(Depth);
    if Depth = CaptureDepth then Exit;                           // 子树已闭合
  end;
  if (Reader.NodeType = xmlntEndElement) or
     ((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
    while (Namespaces.Count > 0) and
          (NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
      Namespaces.Delete(Namespaces.Count - 1);                   // 离开作用域
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

这个循环里有三个细节撑着正确性。从最内层绑定往外走栈、并在 Seen 里记住每个前缀,实现的是遮蔽:如果更近的祖先重新绑定了 xmlns:table,更近的那个值胜出,正如 §6.1 要求的那样。跳过元素自己已经声明过的前缀,避免把同一个属性输出两次——那是另一种格式良好性错误。而弹出规则在结束标签和空元素上都会触发,因为 <x/> 从不产生 EndElement 事件——XLSX 的 extLst 捕获也吃过这个自闭合的亏。按 Reader.Name 而不是 RawName 来匹配目标则是更低调的一个好处:reader 会把 ODF table 命名空间的 URI 规范成 table 前缀,所以把它拼成 t:data-pilot-tables 的生产者照样能匹配上,而输出出去的片段仍用生产者原来那个前缀

这个循环同样拒绝猜。如果部件在捕获还开着的时候就结束了——content.xml 被截断或格式错误——OdsCaptureDataPilotTablesXml 会抛异常而不是返回半截片段,因为半截片段会在保存时被写回去,把一个受损的输入变成顶着这个库名号的受损输出

片段在保存后的 content.xml 里落在哪?

HotXLS 把捕获到的片段写进 <office:spreadsheet>,紧跟在它生成的 <table:named-expressions> 之后、<table:database-ranges> 之前。<office:spreadsheet> 在 ODF 1.3 Part 3 里的内容模型规定了这些尾部子元素的固定顺序,所以一段逐字内容不能随便追加在写入器当时正好待着的位置,必须落进那个特定的槽位。从调用方看,没有 API,也没有任何要配置的东西;这份定义就跟着一次普通的打开与保存一起走:

捕获到的透视表定义在 HotXLS 的 ODS 保存中落在哪:office:spreadsheet 的子元素按 ODF 固定顺序排列,从生成的 table 元素经 table:content-validations 和 table:named-expressions,逐字的 table:data-pilot-tables 片段插在 table:database-ranges 之前;没有 API,因为这份定义跟着 OpenODS 和 SaveAsODS 一起走
逐字块不能随便追加在写入器当时所在的位置;它随带的那些祖先绑定副本是无害的,因为 Namespaces in XML 允许在嵌套作用域里重新声明前缀
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS('official-pivot.ods') <> 1 then
      raise Exception.Create('open failed');
    Book.Sheets[0].Cells[2, 5].Value := 1250.0;   // 在透视表源区域内编辑
    Book.SaveAsODS('official-pivot-out.ods');
    // 输出里的 content.xml 仍然带着 DataPilot1,以及它的
    // 源区域、字段、目标区域、按钮和 loext:/calcext: 属性
  finally
    Book.Free;
  end;
end;

这份冗余是有意为之,也值得知道。片段根现在会重复 xmlns:table 和 xmlns:calcext,尽管保存后的文档根也声明了它们;Namespaces in XML 允许在嵌套作用域里重新声明前缀,所以这些重复无害。对那个 LibreOffice 样本来说,带过来的集合是全部三十五条根声明,在 8357 个字符的定义之上多了大约两 KB——因为捕获过程并不分析这棵子树实际用了哪些前缀。做一次已用前缀扫描能把它裁掉,也许以后会做;先正确,后紧凑

从 XML 里切子树做逐字重放的规则

总的教训是:一棵子树只有在你把它做成自包含之后才自包含,而一旦忘掉,最先坏的就是命名空间作用域。HotXLS 现在对任何「保留我们没建模的东西」的捕获都套用这份清单:

  • 用真正的 reader 遍历文档,跟踪作用域里的绑定。用 Pos 做字符串搜索根本看不见作用域,而且还会在同名的嵌套元素上、在注释或 CDATA 段里恰好匹配的字符串上、在属性值碰巧包含标签文本时全部失手
  • 把生效的绑定复制到片段根上,由内向外,每个前缀只一次,跳过根已经声明过的那些
  • 输出标签里保留原始前缀拼法;按解析后的命名空间匹配目标,而不是按字面前缀
  • 保留空白文本节点,并记住空元素会在没有结束标签事件的情况下关掉自己的作用域
  • 用一个不是被测库的解析器去校验保存后的部件。库会高高兴兴地用当初写它的那条宽容代码路径再读回自己的输出

最后一条正是第二次揪出 HXLS-003 的那条。v2.382.0 的验收检查是一个正则表达式,数保存后 content.xml 里 data-pilot-table 起始标签的个数;而正则表达式看到的是标签,不是文档——它对那个标签上的前缀绑没绑完全瞎。v2.382.1 加进来的严格语料运行器用能感知命名空间的解析器解析保存后包里的每个 XML 和 .rels 部件,然后把透视树——标签、排序后的属性、文本、子节点,递归地——与原始文件比较。这个比较是展开命名空间之后的,所以前缀换个拼法照样能过,而前缀未绑定绝对过不了

逐字保证的边界在哪

逐字重放保存的是一份定义,而不是理解一份定义,边界也由此而来。HotXLS 没有暴露任何读取、编辑或刷新 ODS 透视表的 API,所以 FRawOdsDataPilotTablesXml 是内部字段,唯一能观察到的行为就是这份定义活了下来。片段是从 reader 事件重新序列化出来的,不是按字节复制的:属性引号和自闭合写法会被归一化,而文本与空白保留。捕获到的 XML 只由 ODS 内容写入器输出,所以从 .ods 打开、另存为 .xlsx 的工作簿会丢掉透视表,而从 .xlsx 打开的工作簿在保存成 .ods 时没有任何东西可以重放——ODS 导入与导出路径的不对称在这里和其他地方一样成立。而且因为这份定义是不透明的,它跟不上你的编辑:在 HotXLS 里把 Sheet1 改名或者挪动源数据,保存后的透视表仍然指向 Sheet1.A2:E30,只能等使用方下次刷新时报出一个坏掉的区域。还有一条顺序上的注意事项:HotXLS 把 AutoFilter 区域作为 <table:database-ranges> 输出在透视片段之后,而语料样本里根本没有 database range,所以同时带筛选和透视表的工作簿,在依赖这两个元素的相对顺序之前,最好先过一遍 ODF schema 校验器

拿你自己手上那个生产者的文件来测,别只用语料样本。命名空间搬运能处理生产者在某位祖先上声明的任何前缀,但如果文档把前缀声明在透视元素自己身上,或者给 table 词汇表用了默认命名空间,那就会走那些 LibreOffice 样本没走到的跳过与遮蔽分支。两条分支都实现了;但语料里都还没有对应样本——而这正是 changelog 条目容易含糊过去的那类区别

v2.382.0 的逐字 data pilot 捕获和 v2.382.1 的命名空间作用域修复,都在当前的 HotXLS Delphi Excel Component 里交付;它的产品页列出了该组件对 Delphi 和 C++Builder 的 ODS、XLSX、XLS 完整读写覆盖