技术文章

HotXLS ODS 互操作:Excel 读得到的公式与规则

要产出 Excel 和 LibreOffice 都能正确读取的 ODS 文件,HotXLS 把每条公式以 OpenFormula 语法写在显式声明的 of: 命名空间下,并把每个值或公式条件格式写两遍:一遍作为每个被覆盖单元格样式上的 <style:map>,这是 Excel 16 唯一会读的形式;一遍作为 calcext:conditional-formats 块,这是 LibreOffice 信任的形式。两个应用各自无视为对方准备的那一半,所以在其中一个里显示正常的文件,对另一个什么也证明不了

最后那句话,就是 v2.384.55 到 v2.384.72 之间六个 HotXLS 版本背后的教训。每个修复都始于这样一份文件:HotXLS 写出、自己回读完美无缺、而两个目标应用之一读错。下面是每个应用实际接受什么、同时满足两者的标记是什么,以及从 Delphi 产出这些标记的 HotXLS API 调用

ODS 文件为什么在一个应用里好好的、在另一个里就坏了?

ODS 文件在一个应用里正常、在另一个里出问题,是因为 Excel 和 LibreOffice 读的是同一个包的不同部分。OpenDocument 给公式和条件格式留了不止一种合法拼法,LibreOffice 又在上面叠了自己的扩展命名空间,每个消费者各取自己实现的那块子集。只对一个消费者做测试的写出器,会心安理得地收敛到被另一个无声误读的标记上

两个应用都不报错。LibreOffice 在解析不了的公式单元格里显示 #VALUE!;Excel 打开工作簿时条件格式干脆缺席,或某条公式被改写成求值为 #NAME? 或常量 0 的东西。只跟自己的输出做往返的写出器,这些一样也看不见。HotXLS 在公式命名空间上正踩过这个坑:它的读取器把 of: 前缀当纯文本匹配,于是每次自往返都通过,而 LibreOffice 在每个公式单元格里显示 #VALUE!

特性Excel 16 读取LibreOffice 26.2 读取
写成 A:A 的整列误读成 A:(A)容忍
写成 [.A:.A] 的整列是是
<style:map> 里的条件格式是,它唯一读的形式calcext 在场时忽略
calcext:conditional-formats 里的条件格式忽略是,优先
带 calcext:operator 属性的 calcext 值规则忽略按「等于 0」导入
拼成 is-true-formula(...) 的 calcext 公式规则忽略按与 0 的值比较导入

ODS 里的 OpenFormula:先声明命名空间,再把语法写对

ODS 里的公式单元格,只有当 table:formula 中的 of: 前缀能解析到一个已声明的 XML 命名空间时,LibreOffice 才读得懂。前缀不是装饰。of: 映射到 urn:oasis:names:tc:opendocument:xmlns:of:1.2,而 msoxl:——HotXLS 为 OpenFormula 翻译器建模不了的公式用的前缀——映射到 http://schemas.microsoft.com/office/excel/formula。v2.384.56 之前,content.xml 根元素用了这两个前缀却不声明它们,LibreOffice 完全认不出公式语法

<!-- v2.384.56 之前:用了前缀却从未声明;LibreOffice 显示 #VALUE! -->
<office:document-content xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" ...>
  <table:table-cell table:formula="of:=SUM([.A1:.A3])" office:value-type="float" office:value="245"/>

<!-- 从 v2.384.56 起:两个公式命名空间都在根上声明 -->
<office:document-content
    xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"
    xmlns:msoxl="http://schemas.microsoft.com/office/excel/formula" ...>

命名空间修好之后,表达式本身还得是合法的 OpenFormula,定义见 OpenDocument 1.3 Part 4。陷阱全在 Excel 语法和 OpenFormula 看着像、其实不是的地方:

  • 单元格引用带方括号、点前缀,$ 标记是引用的一部分:[.$A$1] 和 [.A$1:.$B2] 都是合法 OpenFormula。v2.384.55 之前 HotXLS 写出器丢掉了每个 $,绝对引用回读成了相对引用,只有等谁复制了单元格才出错
  • 整列和整行必须用带括号形式 [.A:.A]、[.$A:.$B]、[.1:.1]、[.$1:.$2]。光杆 of:=SUM(A:A) LibreOffice 容忍,Excel 16 却按 =SUM(A:(A)) 打开并给出 #NAME?,还把行引用和 $A:$B 变成常量 0。HotXLS 自 v2.384.65 起写带括号形式
  • 函数实参用 ; 分隔,不是 ,
  • 引用并集用 ~ 运算符:Excel 的 AREAS((A1,B2)) 变成 AREAS(([.A1]~[.B2]))。把那个逗号翻成 ;,一个并集实参就悄悄变成两个实参
  • 内联数组用 ; 分列、| 分行:Excel 的 {1,2;3,4} 变成 {1;2|3;4}。v2.384.55 之前 HotXLS 产出 {1;2;3;4},四个值的一行

逗号是难点,因为一个 Excel 字符身兼三职。从 v2.384.55 起,HotXLS 写出器翻译时维护一个括号栈:紧跟在名字后的 ( 开启函数调用,其逗号变成 ;;其他任何 ( 是分组括号,其逗号变成 ~;{} 里的逗号是数组列分隔符。加上命名空间修复,LibreOffice 26.2 把全部八条数组和并集探测公式都算对了,包括作用在并集上的 INDEX 和 AREAS

HotXLS 的括号栈示意图,把 Excel 逗号翻译成 OpenFormula:紧跟名字后的括号开启函数调用,其逗号变分号;其他括号是分组,其逗号变并集运算符波浪号;花括号内的逗号是数组列分隔符,如 A1 与 B2 的并集作用于 AREAS
逗号在 Excel 语法里身兼三职,只有随行的括号栈分得清;把并集逗号翻成分号,一个实参就无声变成两个
uses
  lxHandleX;

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Orders');
    Sheet.Cells[1, 1].Value := 120;
    Sheet.Cells[2, 1].Value := 80;
    Sheet.Cells[3, 1].Value := 45;
    Sheet.Cells[1, 2].Value := 0.2;

    // 自 v2.384.65 起写成 of:=SUM([.A:.A])
    Sheet.Cells[1, 4].Formula := 'SUM(A:A)';
    // 写成 of:=[.A1]*[.$B$1];$ 标记自 v2.384.55 起保留
    Sheet.Cells[2, 4].Formula := 'A1*$B$1';

    Book.SaveAsODS('orders.ods');
  finally
    Book.Free;
  end;
end;

翻译器建模不了的公式回落到 msoxl:=,Excel 文本原样保留,这也是 msoxl 声明同样要紧的原因。在当前写出器里,这条路径包括 Sheet2!A1 这类带工作表限定的引用和结构化表引用。HotXLS 导入时会读回 msoxl: 公式,自己的往返不伤表达式,但别的应用怎么对待它们,写出器管不着。消费者依赖的某条公式要是顶着 msoxl: 前缀出来了,发货前请在两个应用里都开一遍文件

只写成 calcext 的条件格式,Excel 为什么看不见?

Excel 16 看不见 calcext 条件格式,因为它读 ODS 条件格式只认单元格样式的 <style:map> 子元素,对 calcext:conditional-formats 块整个无视。一锤定音的实验很短:拿一份 LibreOffice 存的 ODS,删掉 style:map 元素,Excel 读到零条规则;改删 calcext 块,Excel 照样全读到。LibreOffice 恰好反过来。calcext 是 LibreOffice 的扩展命名空间,不在 ODF 标准之内,calcext 规则在场时 LibreOffice 拿它而忽略 style:map

HotXLS 的 ODS 条件格式双通道示意图:每个值或公式规则既写成每个被覆盖单元格样式上的样式映射——Excel 16 唯一读的形式——又写成运算符放在值里的 calcext 条件格式块——LibreOffice 偏好的形式,两个应用各自无声无视另一种拼法
Excel 读样式映射而忽略 calcext,LibreOffice 偏好 calcext 而丢掉映射,两边都不报错;一次 HotXLS 调用写出两种拼法,文件才能在两边都验收通过

v2.384.69 之前 HotXLS 只写 calcext,于是一份高亮完全正常的 ODS 文件在 Excel 里打开,值规则、公式规则一条都没有。HotXLS 现在两种形式都写。style:map 这一半用 OpenDocument schema(ODF 1.3 Part 3)的条件语法,拼法与 Excel 16 和 LibreOffice 26.2 保存 ODS 时产出的完全一致:

<!-- 已简化。A1:A50 每个单元格的载体样式(两条值规则) -->
<style:style style:name="ce3" style:family="table-cell">
  <style:map style:condition="cell-content()&gt;100"
             style:apply-style-name="CF_Hit"
             style:base-cell-address="Orders.A1"/>
  <style:map style:condition="cell-content-is-between(1,10)"
             style:apply-style-name="CF_Low"
             style:base-cell-address="Orders.A1"/>
</style:style>

<!-- C1:C50 每个单元格的载体样式(一条公式规则) -->
<style:style style:name="ce4" style:family="table-cell">
  <style:map style:condition="is-true-formula(COUNTIF([.$C:.$C];[.C1])&gt;1)"
             style:apply-style-name="CF_Dup"
             style:base-cell-address="Orders.C1"/>
</style:style>

style:map 的麻烦在于它长在单元格样式上,所以是逐单元格的。规则区域里的每个单元格都得带一个持有映射的样式,空单元格也不例外,否则这条规则在 Excel 里就是不覆盖那个单元格。HotXLS 复制每个单元格既有的格式样式、追加映射,并按「原样式加映射文本」这对组合去重载体样式,于是 500 个格式完全相同的单元格仍然只产出一个样式。写出器还会把写出的表格延伸到规则区域,规则内的空尾行会发出而不是丢掉。自 v2.384.69 起 styles.xml 还带一个空的 Default 单元格样式,style:apply-style-name="Default" 因此总有目标可指

LibreOffice 实际接受的 calcext 拼法

只有比较运算符是值文本的一部分(比如 >3 或 between(1,10))时,LibreOffice 才接受 calcext 值规则;公式规则只有拼成 formula-is(...) 才接受。这两点各让 HotXLS 赔了一个版本,因为错误拼法产出的规则导入时无声无息,然后匹配到错误的单元格

第一个错误是在 calcext:value 旁边放 calcext:operator 属性。读着顺理成章,但它是杜撰的:LibreOffice 不认识那个属性,于是每条值规则都按「等于 0」导入。第二个是把 style:map 的拼法 is-true-formula(...) 塞进 calcext 条件,LibreOffice 同样按与 0 的单元格值比较导入。公式修复随 v2.384.66 发布,值修复在 v2.384.69:

<!-- 错:LibreOffice 无视 calcext:operator,按「等于 0」导入 -->
<calcext:condition calcext:apply-style-name="CF_Hit"
                   calcext:operator="greater-than" calcext:value="100"/>

<!-- 对:运算符放进值里 -->
<calcext:condition calcext:apply-style-name="CF_Hit"
                   calcext:value="&gt;100" calcext:base-cell-address=".A1"/>
<calcext:condition calcext:apply-style-name="CF_Low"
                   calcext:value="between(1,10)" calcext:base-cell-address=".A1"/>

<!-- 对:公式规则用 formula-is,相对引用锚定在基单元格 -->
<calcext:condition calcext:apply-style-name="CF_Dup"
                   calcext:value="formula-is(COUNTIF([.$C:.$C];[.C1])&gt;1)"
                   calcext:base-cell-address=".C1"/>
HotXLS 对比 calcext 条件错误与正确拼法的示意图:calcext operator 属性是杜撰的,会让每条值规则按等于 0 导入;运算符应放在值里,如大于 100 或 between 1 与 10;公式规则必须写 formula-is 并锚定基单元格,而不是样式映射的 is-true-formula 拼法
两种错误拼法导入时都不报错、然后匹配错误的单元格,被读成等于 0 的规则一条想要的高亮都不点;修法是运算符进值、表达式用 formula-is

基单元格赋予相对引用意义。HotXLS 把每条规则锚定在其第一个区域左上角,为 C1 写的公式沿区域向下按 C2、C3 求值,与 Excel 自家条件格式的做法完全一致。规则表达式走的与单元格公式同一个翻译器,数组、并集、整列和 $ 标记都按上文所述形式产出。Delphi 这边加规则的方式与 .xlsx 文件一模一样

uses
  lxHandleX;

procedure AddOrderHighlights(Book: TXLSXWorkbook; Sheet: TXLSXWorksheet);
var
  Idx: Integer;
  Opts: TODSExportOptions;
begin
  // 值规则:style:map cell-content()>100 加 calcext 值 ">100"
  Idx := Sheet.AddConditionalFormat('A1:A50', xlsxCfOpGreaterThan, '100');
  Sheet.ConditionalFormats[Idx].Style.SetFillBgColor($00C0C0FF); // BGR:浅红

  Idx := Sheet.AddConditionalFormat('A1:A50', xlsxCfOpBetween, '1', '10');
  Sheet.ConditionalFormats[Idx].Style.SetFontBold(True);

  // Excel 语法的公式规则(逗号分隔,相对 C1):
  // style:map is-true-formula(...) 加 calcext formula-is(...)
  Idx := Sheet.AddCondFormatExpression('C1:C50', 'COUNTIF($C:$C,C1)>1');
  Sheet.ConditionalFormats[Idx].Style.SetFillBgColor($00CCFFFF); // BGR:浅黄

  Opts := TODSExportOptions.Create;
  try
    Opts.Generator := 'OrderExport 3.1';
    Book.SaveAsODS('orders.ods', Opts);
  finally
    Opts.Free;
  end;
end;

把 Excel 与 LibreOffice 的 ODS 读回 Delphi

HotXLS 打开 ODS 文件时,读取器两种条件格式方言、两种 calcext 拼法都收,文件同时带两种形式时也不会把一条规则数两遍。真实文件来自三类写出器,各有各的习惯:

  • 新旧 calcext。带 calcext:operator 属性的文件——包括 HotXLS v2.384.69 之前写出的 ODS——仍走旧式解析。公式条件 formula-is(...) 和 is-true-formula(...) 两种拼法都认
  • Excel 的 style:map 拼法。Excel 给条件加 of: 前缀,如 of:cell-content-is-between(1,10),且值规则省略基单元格。两者都接受
  • 空单元格。Excel 和 LibreOffice 都把空单元格的映射放在列默认样式上而不是单元格上,所以读取器在收集映射之前先解析重复单元格的列默认样式
  • 区域重建。映射逐单元格收集,读完一张表后,读取器把条件与基单元格都相同的单元格重新合并成区域,先横向扫每一行、再纵向对齐列段,并丢弃已从 calcext 读到的规则

v2.384.72 的修复关于数字样式,与规则无关。Excel 16 和 LibreOffice 26.2 都把 General 格式写成 number:number 元素不带 number:decimal-places 的数字样式,典型如 <number:number number:min-integer-digits="1"/>。HotXLS 读取器把缺失的位数当成两位固定小数,于是 Default 样式里每个值都带着 0.00 导入,1.5 显示成 1.50。从 v2.384.72 起,没有小数位、没有最小小数位、没有分组、整数位至多一位的朴素 number 元素映射到 General,而孤零零的 General 让单元格干脆没有任何数字格式。环绕的文本保留,如 General" kg";带分组的数字维持原映射,因为 Excel 没有带分组的 General 格式

uses
  SysUtils, lxCondFormat, lxHandleX;

procedure DumpOdsRules(const FileName: string);
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Rule: TXLSXConditionalFormat;
  I: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open(FileName) <= 0 then
      raise Exception.Create('cannot open ' + FileName);
    if Book.SourceFormat <> xlsxOpenDocumentSpreadsheet then
      raise Exception.Create('not an ODS package');

    Sheet := Book.Sheets[1]; // Sheets 索引器从 1 起算
    for I := 0 to Sheet.ConditionalFormats.Count - 1 do
    begin
      Rule := Sheet.ConditionalFormats[I];
      case Rule.Kind of
        cfkCellIs:
          Writeln(Rule.Range, ' value rule ', Ord(Rule.Op), ' ',
            Rule.Formula1, ' ', Rule.Formula2);
        cfkExpression:
          Writeln(Rule.Range, ' formula rule ', Rule.Formula1);
      end;
    end;

    // Excel General 样式里的单元格回读时没有数字格式
    // 自 v2.384.72 起,不再是 '0.00'
    Writeln('A2 format: "', Sheet.Cells[2, 1].NumberFormat, '"');
  finally
    Book.Free;
  end;
end;

规则公式以带逗号分隔符的 Excel 语法返回,与你传给 AddCondFormatExpression 的形式相同,HotXLS 写出的规则回读就是同一个字符串。想了解 ODS 导入路径整体上留什么、丢什么,见 HotXLS ODS 打开与保存往返指南;Excel 与 LibreOffice 的重复行在导入时如何展开,见 ODS 重复行即行高 run

HotXLS 的 ODS 条件格式互操作边界在哪?

双标记方案覆盖值比较规则和公式规则,到此为止。其余的要么单边支持,要么干脆不写:

  • 色阶和数据条只写成 calcext 元素,LibreOffice 显示它们,Excel 不显示
  • 其他规则类型——图标集、文本规则、前 N 项、高于平均值、重复值规则——当前写出器没有 ODS 输出。文本规则通常可以改写成公式规则,比如在 B2:B200 上用 ISNUMBER(SEARCH("late",B2)),这样就能送达两个应用
  • 整列整行规则如 C:C 只铺在实际写出的表格区域上,而不是全部 1,048,576 行,所以 Excel 只在文件里存在的单元格上看到这些规则
  • 只有 style:map 的文件。文件没有 calcext 块时,HotXLS 从重建区域的左上角解释公式规则里的相对引用,而不是从声明的基单元格平移
  • LibreOffice 的重叠规则。一个单元格被多条规则覆盖时,LibreOffice 只把第一条规则的映射写到它上面。这类文件只靠 style:map 读不全,这是读取器在两者都在场时偏好 calcext 的又一个理由

流程层面的边界比上面任何一条都重要。这些版本背后的缺陷,全都越过了「写出 ODS 再用 HotXLS 读回」的往返,有一些甚至能在错误的应用里通过人工检查:整列公式在 LibreOffice 里好好的,Excel 却显示 #NAME?;公式规则从 v2.384.66 起在 LibreOffice 里工作,Excel 那边直到 v2.384.69 之前一条规则都看不见。如果 ODS 互操作是硬需求,验收测试就是在 Excel 和 LibreOffice 里各开一遍文件、比较各自显示什么。规则指向的样式同样适用这条纪律;HotXLS 条件格式与样式一文讲了高亮样式在工作簿一侧怎么定义

速查:两个应用都读的 ODS

  • 在 content.xml 根上声明 xmlns:of 和 xmlns:msoxl,否则 LibreOffice 对每条公式显示 #VALUE!(HotXLS 自 v2.384.56 起)
  • 引用写成 [.A1],每个 $ 都保留,整列整行写成 [.A:.A] 和 [.1:.1](自 v2.384.55 和 v2.384.65 起)
  • 实参用 ;,引用并集用 ~,内联数组行间用 |
  • 每个值或公式规则,为 Excel 写成每个被覆盖单元格样式上的 <style:map>,为 LibreOffice 写成 calcext 条件(自 v2.384.69 起)
  • calcext 里,运算符放进值(>3、between(1,10)),公式规则拼作带基单元格的 formula-is(...)(自 v2.384.66 和 v2.384.69 起)
  • 导入时会遇到不带 number:decimal-places 的 General 数字样式;HotXLS 自 v2.384.72 起按 General 读它
  • 每个新的导出配置都开两个应用验收——Excel 和 LibreOffice 各开一遍,绝不只开其一

HotXLS 是原生 Delphi 和 C++Builder 电子表格库,无需安装 Excel 或 LibreOffice 即可读写 XLS、XLSX 和 ODS;完整源码、功能清单与授权见 HotXLS Delphi 电子表格组件页