技术文章

Delphi中XLSX共享公式si展开:常见陷阱

XLSX中一个共享公式的从属单元格本身不携带任何公式文本。它的<f t="shared" si="N"/>元素指向工作表中别处的一个主单元格,读取器必须按行列差值平移主公式,重新构建出文本。面向Delphi和C++Builder的HotXLS Component在打开文件时就完成这次展开,所以每一个从属单元格报告出来的都是一个完整的公式

如果你曾经在某个第三方库里加载过一份真实世界的XLSX文件,发现一整列上千个公式,恰好只有一个单元格里有文本,其余999个都是空字符串,那你已经从错误的一侧撞见过这个特性了。文件没有任何损坏。它做的正是ECMA-376所允许的事,只是读取器在XML停下的地方也跟着停下了

为什么共享公式单元格是空的?

因为这个格式是刻意只存储一次公式的。在ECMA-376 Part 1和ISO/IEC 29500-1中,<f>元素(§18.3.1.40)携带一个ST_CellFormulaType类型的t属性,取值为shared时表示这个单元格属于一个由si属性标识的分组。这个分组里恰好有一个单元格,也就是主单元格,还额外携带一个ref属性,给出这个分组适用的范围,也只有这个单元格把公式文本作为元素内容写出来。分组里的其他单元格都是从属单元格。它们同样带着t="shared"和相同的si,但元素内容为空。Excel会非常积极地写出这类分组,因为对一个20万行的列做向下填充,能把20万个公式字符串压缩成一个字符串加上199,999个极小的占位元素。这样节省下来的空间是实实在在的,代价则完全落在读取器这一侧:如果不做展开,从属单元格本身没有任何意义

这次平移是一次转换,不是文本拷贝

HotXLS解析一个从属单元格的方式是:先找到注册在同一个si下的主单元格,计算出从主单元格锚点到当前单元格的行列差值,再按这个差值平移主公式中的每一处引用。相对维度会移动,绝对维度不会移动,混合引用只有它非绝对的那一半会移动。字符串字面量则完全跳过不处理,所以一个恰好包含文本"A1"的公式,在每一个从属单元格里这段文本都保持不变

const
  // xl/worksheets/sheet1.xml, trimmed to the interesting cells
  SheetXml: WideString=
    '<row r="1"><c r="A1"><v>1</v></c>'+
    '<c r="B1"><f t="shared" si="4" ref="B1:B3">'+
    'A1+$A$1+A$1+$A1+&quot;A1&quot;+SUM(A1:A2)</f><v>7</v></c></row>'+
    '<row r="2"><c r="B2"><f t="shared" si="4"/><v>8</v></c></row>'+
    '<row r="3"><c r="B3"><f t="shared" si="4"></f><v>9</v></c></row>';

var
  Wb: TXLSXWorkbook;
  Sh: TXLSXWorksheet;
begin
  Wb:= TXLSXWorkbook.Create;
  try
    Wb.Open(FileName);
    Sh:= Wb.Sheets[1];
    // Master, verbatim
    // B1 -> A1+$A$1+A$1+$A1+"A1"+SUM(A1:A2)
    // Follower one row down: relative row moves, absolute row frozen,
    // the mixed A$1 keeps its row, and the literal stays a literal
    // B2 -> A2+$A$1+A$1+$A2+"A1"+SUM(A2:A3)
    ShowMessage(Sh.Cells[2, 2].Formula);
  finally
    Wb.Free;
  end;
end;

ref属性是一道闸门,不是装饰。一个坐标落在主单元格适用范围之外的从属单元格不会被展开,因为这样的文件是在提出一个这个分组本身并不支持的主张。同样,当一次平移会把某个引用推到第一行之上或者A列之左时,HotXLS会为那个token输出#REF!,而不是悄悄地把它钳制在边界内——这正是Excel自己在同样的编辑操作下会产生的结果。这种转换和插入或删除行时发生的引用重写是近亲,但并不是同一回事。那条路径有它自己的一套规则,规定一次编辑切过一个范围时该范围该如何变化,在插入删除行时的公式引用调整一文中有单独介绍。共享展开要简单得多:它是从一个已知锚点出发的一次纯粹的偏移运算,在解析时执行一次即可

这个平移逻辑必须覆盖哪些引用形态?

全部都要覆盖,否则这次展开就是一个伪装起来的数据丢失bug。一个只认识A1A1:B2的朴素平移逻辑,会破坏或丢弃更多不常见的形式,而真实的工作簿里到处都是这些形式。HotXLS的共享公式转换器会在决定要移动什么之前,先识别出整个A1家族的写法。像[Book.xlsx]Sheet1!A1这样的外部工作簿引用,以及像Sheet1:Sheet3!A1这样的三维引用,会保持它们的前缀不变,只有末尾的单元格引用部分参与平移。带引号的工作表名称也能正确保留,包括那种棘手的情况——工作表名字本身恰好就叫A1,这样'A1'!A1就只有感叹号之后的部分会被平移。整列引用A:A只移动它的列维度,别的什么都不动;整行引用1:1只移动它的行维度,别的什么都不动;$A:$A则完全不移动。像Table[A1]这样的结构化表引用会被原样保留,因为方括号里的部分是列名,不是坐标

// One master, expanded two columns to the right and zero rows down.
// Master D1: A1+A:A+$A:$A
// F1       : C1+C:C+$A:$A
//
// One master, expanded three rows down and zero columns across.
// Master A1: B1+$C$1+"A1"+A:A+1:1+'Data'!A1+LOG10(A1)+Table[A1]+'A1'!A1
// A3       : B3+$C$1+"A1"+A:A+3:3+'Data'!A3+LOG10(A3)+Table[A1]+'A1'!A3
//
// Note what did NOT move in the second line: the absolute $C$1, the
// string literal "A1", the whole column A:A under a pure row delta,
// the function name LOG10, and the structured reference Table[A1]

函数名是这里一个不动声色的陷阱。一个只是抓取"字母后面跟数字"这种模式的token扫描器,会毫不犹豫地把LOG10在下移一行时重写成LOG11。HotXLS要求一个候选token的前面和后面都必须是引用边界,所以一个后面接着字母、数字、下划线、点号或左括号的标识符,就不会被当作单元格引用。如果你用的是另一套表示法体系,同样的边界问题会以不同的形式出现,R1C1表示法一文介绍了这两套模型的分歧之处

为什么一个自闭合的f元素会把下一个值吞掉?

因为一个自闭合元素不会产生结束元素事件。这是整个功能里代价最高的一个bug,而且它并不特定于某一个XML解析器。在TXMLReader中,<f t="shared" si="4"/>恰好只会触发一次Element事件,IsEmptyElement被设为True,而永远不会触发对应的EndElement。一个只在EndElement时才结束公式捕获状态的解析器,因此会一直停留在"正在读公式"这个状态里,它接下来看到的文本——也就是<v>里缓存的结果值——就会被追加进公式缓冲区。更糟的是,这个状态会跨越单元格边界继续存活,所以下一个真正带有<f>的单元格,它的公式文本会被前一个单元格吞掉。修复方式是:只要IsEmptyElement为True,就在Element事件本身触发时就结束公式状态,并在那里立即完成整个从属单元格的解析工作,而不是等待。这意味着要在处理空元素的这个分支里,一次性完成从属性中读取tsirefacaca,执行共享展开,把重算相关的属性写回单元格,并清除共享状态。请注意,这个格式同时允许两种写法,<f t="shared" si="4"/><f t="shared" si="4"></f>,而后者确实会触发一次EndElement。一个正确的读取器必须对这两种写法一视同仁,这正是HotXLS在同一个回归测试文件里同时覆盖这两种写法的原因

稀疏、无序的si取值与待处理队列

si属性是文件提供的一个无符号整数,不是一个你能自己掌控的数组位置。规范里没有任何地方要求共享索引必须是连续的、必须从零开始、或者必须按升序出现,也没有任何东西能阻止一份恶意或者只是形态怪异的文件在第一个单元格上就用si="4294967290"。所以按观察到的最大si值来分配查找数组的大小,不是一种优化,而是一个内存耗尽的原语。HotXLS在打开工作簿的路径上转而使用一张有序的稀疏表:共享分组按它们的整数键注册进一个有序的TStringList,这让查找变成一次二分搜索,搜索范围只取决于实际存在多少个分组,与索引数值的大小毫无关系。顺序是这个问题的另一半。通常主单元格在文档顺序上会出现在它的从属单元格之前,但这只是一种惯例,不是一条规则,所以任何在解析当下无法解析出自己si的从属单元格,都会被放进一个待处理队列。工作表解析完成后,这个队列会针对此时已经完整的表重新执行一遍,迟到的主单元格会解析出它们的孤儿。始终找不到主单元格的单元格会保留一个空公式,对于一份引用了自己从未定义过的分组的文件来说,这才是诚实的结果

不加载整个工作簿也能展开共享公式

流式读取器在内存预算紧得多的情况下面对的是同一个需求,它们用一张工作表局部的表来解决这个问题。TXLSDirectReaderTXLSRowCursor都会把从属单元格展开成完整的按单元格公式,同时保持它们各自有界内存和投影读取的特性,所以对一份300MB工作表做一次只能向前的遍历,依然能拿到真实的公式文本

var
  Reader: TXLSDirectReader;
  Cursor: TXLSRowCursor;
begin
  // Projection: only rows 2..3, only column A. The master lives in row 1,
  // outside the projection, and is still parsed so the followers resolve
  Reader:= TXLSDirectReader.Create;
  try
    Reader.FirstRow:= 2;
    Reader.LastRow:= 3;
    Reader.IncludeColumn(1);
    Reader.OnCell:= HandleCell;   // Cell.Formula is fully expanded here
    Reader.ReadFile(FileName);
  finally
    Reader.Free;
  end;

  // Forward-only row traversal, same expansion
  Cursor:= TXLSRowCursor.Create;
  try
    Cursor.Open(FileName);
    if Cursor.FindFirst then
      repeat
        if Cursor.CellCount > 0 then
          WriteLn(Cursor.RowIndex, ': ', Cursor.Cells[0].Formula);
      until not Cursor.FindNext;
  finally
    Cursor.Free;
  end;
end;

这种设计带来了两条约束。第一,投影读取永远不能跳过主单元格。用FirstRowLastRow设置的行过滤器,或者用IncludeColumn构建的列过滤器,可以不把主单元格交付给你的回调,但解析器仍然必须记录它的si、锚点坐标、适用范围和公式文本,否则投影范围内的每一个从属单元格都会解析成空。只有从属单元格这一侧的工作——平移和值解码——才可以安全跳过。第二,这张表是按工作表存在的,它的生命周期必须显式管理:TXLSRowCursor在一次工作表遍历期间持有一个实例,并在重新开始、切换工作表、到达文件末尾、抛出异常和关闭时清空它,这样一个在第一个工作表中定义的分组就绝不会泄漏进第二个工作表。由于流式路径是一条热点循环,它用的是开放寻址的整数哈希表,而不是有序字符串表,这样就避免了每个单元格都要做一次整数到字符串的转换

保存时会发生什么,边界又在哪里

一旦一个从属单元格被展开,它就变成了一个普通公式,HotXLS会把它作为一个独立的<f>元素写回去,不带t="shared",也不带si。这个往返过程是稳定的,缓存的<v>结果也能保留下来,但对一个大量使用共享公式的工作表来说,输出会比输入大,而且Excel当初创建的分组结构在保存时不会被重建。如果共享分组的字节级保真度对你来说比"每个单元格都有真实公式文本"更重要,这就是你要接受的取舍。顺带一提,XLS这一侧的情况不同:BIFF8的SHRFMLA记录有它自己独立的编码方式和写入器,工作簿上还带着一个共享分组的开关

还有两个相关的东西,虽然共用<f>这个元素,但明确不属于共享公式。旧式的CSE数组公式使用t="array",配合一个覆盖锚定范围的ref;动态数组也使用同样的t="array"写法,但要通过一个cm属性来识别,这个属性通过cellMetadata链接到一条XLDAPR记录。把一个动态数组的溢出单元格当成共享公式或CSE的从属单元格来处理,是一个真正的正确性bug,这两者的区分在动态数组与溢出公式一文中有介绍。把这三种情况当作三个恰好共用了同一个标签名的解析器来看待,代码才能保持诚实

这里介绍的共享公式展开、流式读取器和引用转换器,都是HotXLS Excel组件的一部分,适用于Delphi和C++Builder;产品页收录了完整的公式与直读API参考,包括上文用到的这些投影属性