技术文章

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

一份带一个透视表的 ODS 工作簿经过某库走一遭,只改了一个源单元格,保存出来的文件打开时单元格值完好无损,透视表却没了:content.xml 进去时含一个 <table:data-pilot-table>,出来时是零个。显而易见的修法是把透视 XML 当字符串原样拷过去。元素数量是回来了,接着感知命名空间的解析器却以未绑定前缀错误把保存出的部件整个拒收。两种失败同根同源:一段 XML 只有当它依赖的命名空间声明跟着一起走,才能安全走完这段旅程

ODS 透视表定义在 content.xml 的哪个位置

OpenDocument 透视表(格式里叫 data pilot)是一份存在 content.xml 中工作表之后的定义,一个从自己的对象模型重建该部分的写入器,只有当模型里有地方放它时才会保留它。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 列表。透视表显示的结果单元格作为普通单元格待在工作表里。所以一个只重新生成单元格、样式、有效性、命名表达式和数据库范围的写入器,产出的电子表格完全合法,屏幕上数字一模一样,背后却没有透视表

HotXLS Delphi Excel Component 在 OpenODS 里、解析任何单元格之前,就把整个 <table:data-pilot-tables> 子树从 content.xml 里读出来,然后尝试把这个集合翻译成它的透视模型。定义若是模型能表达的朴素形状——局部源区域、一个行字段、聚合度量以及与源数据一致的存储结果——就会变成工作表 PivotTables 列表里的一个 TXLSPivotTable,与 XLSX 透视表是同一类对象,可以在 Delphi 里加计算字段并刷新。超出该形状的东西让集合以捕获 XML 的形式保留,SaveAsODS 时原样写回。翻译器不认识的命名空间里只要有一个属性就够了:LibreOffice 保存的透视表可能带 loext: 和 calcext: 属性,一个 loext: 属性就会把定义送进捕获路径

这个兜底是按工作簿全有或全无的。三个透视表的集合里只要有一个超出可翻译形状,三个都保持捕获 XML。拆开处理会让 content.xml 对同一个容器出现两个事实来源,一半从对象重建、一半从文本重放,两半之间任何不一致都会落到用户的文件里。让集合保持完整是更安全的默认,这也是 HotXLS 对 XLSX 里未知的 extLst 块采用的同一策略:模型装不下的,按原样保留

被切出来的透视表定义为什么会弄坏保存的文件?

用字符串搜索从 content.xml 里切出的透视表定义,丢掉了赋予其前缀意义的命名空间声明,而 Namespaces in XML 把没有声明的前缀当作良构性错误。字符串做法很短:把该部分解码成 WideString,用 Pos 找 <table:data-pilot-tables,再找它后面匹配的关闭标签,中间全部拷走。得到的文本看上去是完整的:页面字段上有 loext:ignore-selected-page="true",每一层都有 calcext:repeat-item-labels="false",但绑定这些前缀的 xmlns:loext 和 xmlns:calcext 声明挂在文档根 <office:document-content> 上,在几千个字符之前

Namespaces in XML 1.0 §6.1 规定声明的作用域从携带它的起始标签到匹配的结束标签,其间每个带前缀的名字都对着它解析。把子树从文档里切出来,就把它切出了这个作用域。片段粘进新文档后,每个前缀只有在新根恰好声明了它时才能解析。HotXLS 写自己的 <office:document-content>,带上它写入器用到的命名空间,其中有 office、table、text、style、fo、draw、calcext、tableooo 和 of。切出来的副本里 table: 能解析,calcext: 能解析,loext: 不能。一个未绑定前缀就足以让感知命名空间的解析器拒收整个部件,透视表不会优雅降级——它周围的工作表数据也跟着读不了

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

字符串搜索还有一些基于读取器的捕获根本碰不到的盲区。Pos 会匹配注释里的标签文本、CDATA 段里的,或者某个引用它的属性值里的。它分不清同名嵌套元素。它匹配的还是字面前缀 table:,而 ODF 允许生产者把 table 命名空间绑定到任何前缀上

HotXLS 如何把命名空间作用域带到片段上

HotXLS 用一个流式 XML 读取器捕获透视子树,它把每条命名空间声明连同出现的深度一起记录,目标元素到达的那一刻,把仍在生效的声明抄到片段的开始标签上。栈在每个声明 xmlns 或 xmlns:prefix 的开始标签处生长,在声明绑定的元素关闭时收缩。空元素要同样对待:<x xmlns:p="..."/> 不会产生单独的结束标签事件,所以出栈也必须在空开始标签上触发,否则绑定会泄漏给它的兄弟节点

到目标处,捕获从最内层绑定向外遍历栈,每个前缀只写一次。由内向外走实现的正是遮蔽:如果 <office:spreadsheet> 重新绑定了根已声明的前缀,在透视表处生效的是较近的值,与 §6.1 的要求一致,而已写前缀的集合挡住了外层值跟着写进来。透视表元素自己在自己身上声明的前缀被跳过,因为在一个标签上写两次 xmlns:table 是另一种良构性错误

HotXLS 如何带着命名空间作用域捕获 data pilot 子树:流式 XML 读取器一遍扫描维护一个带声明深度的 xmlns 绑定栈,在 table:data-pilot-tables 目标处由内向外遍历,靠已写前缀集合实现遮蔽,跳过元素自己声明的前缀,结束标签与空元素同样出栈
按命名空间而非字面前缀匹配目标,改写 table 前缀的生产者照样被认出来;子树不闭合就抛异常,而不是把半个片段写回保存的文件

目标按命名空间匹配,所以写出 <t:data-pilot-tables>(把 t 绑定到 ODF table URI)的生产者照样被认出来,捕获的标签保持文件里的写法。空白文本节点按原样返回。属性的引号风格和 <x/> 与 <x></x> 之间的选择会被归一化,因为片段是从读取器事件重新序列化的,不是逐字节拷贝。如果 content.xml 在透视子树还开着时结束——截断文件正是如此——打开就以异常失败,而不是留半个定义等下一次保存写回去

带走的集合刻意宽松。捕获把作用域里的每条绑定都抄上,不管子树用不用,所以片段根会重复 xmlns:table 和 xmlns:calcext,哪怕保存出的文档根也声明了它们。在嵌套作用域里重新声明前缀在 Namespaces in XML 下是合法的,根上声明了几十个命名空间的文件会多出几 KB。修剪这份清单意味着扫描整个子树,找出元素名、属性名以及承载 QName 的属性值里的每个前缀,漏掉一个,未绑定前缀的失败就立刻回来。几条冗余声明的代价比这个风险小得多

打开、编辑并保存一个带透视表的工作簿

从调用方看没有任何开关要拨:透视表定义跟着普通的 OpenODS 和 SaveAsODS 走。工作表从 1 开始编号,两个方法成功都返回 1:

uses
  System.SysUtils, lxHandleX;

procedure EditPivotSource(const InFile, OutFile: string);
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS(InFile) <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [InFile]);
    // E2 位于 Sheet1.A2:E30 这个透视源区域内
    Book.Sheets[1].Cells[2, 5].Value := 1250.0;
    if Book.SaveAsODS(OutFile) <> 1 then
      raise Exception.CreateFmt('Cannot save %s', [OutFile]);
  finally
    Book.Free;
  end;
end;

在保存出的 content.xml 里,透视集合位于 <office:spreadsheet> 内,紧跟写入器生成的 <table:named-expressions> 之后、<table:database-ranges> 之前。既带 AutoFilter(HotXLS 写成数据库范围)又带透视表的工作簿,会把这两个元素放在一起;如果你的流水线跑严格的 ODF schema 校验,这一对就是首先要看的地方

捕获的透视表定义落在 HotXLS ODS 保存的哪个位置:先是 table:content-validations 和生成的 tables,然后是 table:named-expressions、逐字重放的 table:data-pilot-tables 片段和 table:database-ranges;没有专门的 API,定义跟着 OpenODS 与 SaveAsODS 走
逐字重放的块写在 office:spreadsheet 内的一个固定位置,它带走的祖先绑定副本无害,因为 Namespaces in XML 允许在嵌套作用域里重新声明前缀

这个示例里的编辑改的是源值,透视表的结果单元格保持原样。捕获的定义按原样重放:HotXLS 不重算它,打开文件的电子表格应用会在下次刷新时重算透视表。地址也一样。把 Sheet1 改名或挪动源块,保存出的定义仍然指向 Sheet1.A2:E30,消费端应用刷新时会报告这是一个坏区域。透视表走这条路的工作簿,表名和源块都要保持稳定

某个透视表走的是哪条路径?

最快的检查是工作表的 PivotTables 列表:被翻译的定义在那里以 TXLSPivotTable 出现,被捕获的不会。转换报告回答问题的另一半:透视表换成别的输出格式会怎样。GetConversionReport 只检查工作簿,不保存也不重算,返回的报告由调用方释放:

uses
  System.SysUtils, lxHandleX, lxDiagnostics;

function DescribeOdsPivots(Book: TXLSXWorkbook): string;
var
  I, Modeled: Integer;
  Report: TXLSConversionReport;
begin
  Modeled := 0;
  for I := 1 to Book.Sheets.Count do          // Sheets 从 1 开始
    Inc(Modeled, Book.Sheets[I].PivotTables.Count);

  Report := Book.GetConversionReport(xlsConversionOpenXmlWorkbook);
  try
    for I := 0 to Report.Count - 1 do
      if (Report[I].Feature = 'OpenDocumentPivotTables') and
         (Report[I].Fidelity = xlsConversionDropped) then
        Exit('Captured ODF pivot XML: keep this workbook on .ods');
  finally
    Report.Free;
  end;

  if Modeled > 0 then
    Result := Format('%d pivot table(s) in the pivot model', [Modeled])
  else
    Result := 'No pivot definitions';
end;

输出路径很重要,因为捕获 XML 是 ODF 词汇,只有 ODS 写入器会输出它。存成 .xlsx,捕获集合没有 SpreadsheetML 对应物,报告把它列在 OpenDocumentPivotTables 下并标 xlsConversionDropped。二进制 .xlsb 写入器直接拒绝还持有它的工作簿。被翻译的透视表是普通模型对象,遵循目标格式的规则。ODS 导入与导出路径的差异在这里和其他地方一样适用,对带透视表的文件,安全的路线是 .ods 进、.ods 出

全有或全无的兜底还推出一条规则。一份工作簿要么重放捕获集合,要么从模型写透视表,绝不会在同一个 content.xml 里混用两者。对已经持有捕获透视 XML 的工作簿调 AddPivotTable 加透视表,ODS 保存会以错误停止,而不是把生成的 XML 拼到重放 XML 旁边

用第二个解析器核对保存出的部件

元素数量证明不了命名空间没问题。在保存出的 content.xml 里找 data-pilot-table 开始标签的正则看到的是文本,第二节那份切出来的副本也能通过那个测试。一个与写出该文件的库毫无关系的感知命名空间解析器是严格得多的裁判,而 Delphi 本来就自带一个。下面这个函数是给你加进自己测试套件的检查;TZipFile 和 TXMLDocument 来自 Delphi RTL,不来自 HotXLS:

uses
  System.SysUtils, System.Classes, System.Zip,
  Xml.xmldom, Xml.XMLIntf, Xml.XMLDoc;

// 你自己的测试辅助函数:.ods 包里的 content.xml
// 能在感知命名空间的 DOM 解析器中加载时返回 True
function OdsContentParses(const OdsFile: string; out Error: string): Boolean;
var
  Zip: TZipFile;
  Bytes: TBytes;
  Stream: TBytesStream;
  Doc: IXMLDocument;
begin
  Error := '';
  Zip := TZipFile.Create;
  try
    Zip.Open(OdsFile, zmRead);
    Zip.Read('content.xml', Bytes);
  finally
    Zip.Free;
  end;
  Stream := TBytesStream.Create(Bytes);
  try
    Doc := TXMLDocument.Create(nil);
    try
      Doc.LoadFromStream(Stream);     // 未绑定前缀在这里失败
      Result := True;
    except
      on E: EDOMParseError do
      begin
        Error := E.Message;
        Result := False;
      end;
    end;
  finally
    Stream.Free;
  end;
end;

代码保存的每个 .ods 都跑一遍,然后对带透视表的文件更进一步:解析后比较输入与输出的透视子树——标签、排序后的属性、文本和子节点,递归进行,名字展开成命名空间 URI 加本地名。这种比较让改写前缀的文件通过,同时抓住未绑定前缀,恰好在无害与损坏之间划出那条线。把你用户实际在用的每个生产者产出的文件都喂给它。把 table 命名空间绑到别的前缀、在透视表元素上自己声明前缀、或在 <office:spreadsheet> 里重新绑定的文档,对跳过和遮蔽规则的锻炼是单个 LibreOffice 文件给不了的

ODS 读取器与写入器、面向 XLSX 与 ODS 的透视模型,以及上文用到的转换报告,都属于 HotXLS Delphi Excel Component;其产品页介绍对 Delphi 和 C++Builder 的完整 XLSX、XLS、ODS 读写支持