技术文章

用 HotXLS 在 Delphi 中读取 Excel 2.0 至 4.0 文件

HotXLS 可以直接从 Delphi 和 C++Builder 打开由 Excel 2.0、3.0 和 4.0 写出的工作簿。这些文件早于后来所有 .xls 都会用到的 OLE 复合文档容器,因此它们是完全没有存储包装的原始 BIFF 记录流,一个为 BIFF8 编写的读取器在其中找不到任何一处能识别的结构。打开这类文件用的是和其他任何工作簿一样的 Open 调用;读取器会自动检测格式并切换处理路径

这些文件之所以仍然重要,唯一的原因是它们确实还在流通。工程档案、政府记录留存、控制软件写于 1993 年的实验室仪器所产生的数据,以及长年运行的会计系统,都留下了 BIFF2 和 BIFF4 工作簿。现代 Excel 出于安全考虑移除了不少旧版转换器,因此会直接拒绝打开其中一些文件,留下一批谁都用不上现成工具读取的数据

OLE 之前的工作簿有什么不同?

从 Excel 5.0 起,每个 .xls 都是一个 OLE2 复合文件,即文件内部的一个小型文件系统,工作簿数据存放在名为 WorkbookBook 的流中。解析这类文件要先从解析这个容器开始,详见 Pascal 中的复合文件二进制格式

BIFF2 到 BIFF4 没有容器。文件一开始就是一条 BOF 记录,这条 BOF 的记录编号编码了所属的世代:BIFF2 为 $0009,BIFF3 为 $0209,BIFF4 为 $0409。HotXLS 会在切入原始处理路径之前先校验 BOF 主体长度(应在四到六字节之间)以及子流类型:$0010 表示工作表,$0020 表示图表,$0040 表示宏表。正是这道校验,防止一个损坏或误判的文件被当作年代久远的工作簿来解释

三代格式,三种记录布局

单元格记录是各代分歧最明显的地方。BIFF2 占据一段连续的低记录编号,$0001$0005 分别对应空白、整数、数字、标签和布尔或错误单元格,每种主体都携带一个三字节属性字段,而后续版本在同样位置放的是扩展格式索引。BIFF3 和 BIFF4 放弃了这套编号,转而复用 BIFF5 的记录编号和布局:$0201$0203$0204$0205,搭配一个两字节的 XF 索引

最后这一点会导致一种特定且极易误判的故障。BIFF3 或 BIFF4 的 LABEL 记录在结构上与其 BIFF5 对应版本完全相同,依次是行、列、格式索引,再是字符数。如果读取器按 BIFF2 的布局假设去读,就会少读两个字节,然后越过记录末尾,把后面的所有内容都解释错。这时的症状不是一个异常,而是一份读出来带着貌似合理垃圾数据的工作簿

公式记录在这三代中的编号是并行的:$0006$0206$0406。当一个公式产生字符串结果时,该字符串会出现在紧随其后的一条独立记录中,即 $0007$0207,其中 BIFF2 形式用的是单字节长度前缀,而不是后来使用的双字节前缀

为什么公式会以值而非文本形式返回?

HotXLS 在这些文件中会读取公式的缓存结果,而不会尝试重建公式表达式。这是刻意划定的边界,不是尚待填补的缺口

BIFF2 到 BIFF4 中解析出的表达式所使用的令牌编码与 BIFF5 及之后的版本存在超出表面差异的区别:令牌长度前缀方式不同,引用令牌大小不同,函数索引表在各代之间也被重新编过号。把这些字节直接丢给一个 BIFF8 表达式转换器,产生的不是一个错误的公式,而是一个随机的公式。读取缓存值能得到 Excel 上一次计算出的数字或字符串,而这正是一次档案迁移实际需要的东西

缓存值位于记录内一个随世代而不同的偏移处:BIFF2 是第 7 字节,BIFF3 和 BIFF4 是第 6 字节。特殊值,包括字符串、布尔值、错误和空白,用一个标记字 $FFFF 加判别符编码,这与后续 BIFF 世代沿用的约定相同

打开一份文件

调用代码平淡无奇,而这正是重点所在。检测发生在 Open 内部:

uses
  lxHandle;

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  R, C: Integer;
  V: Variant;
begin
  Book := TXLSWorkbook.Create;
  try
    if Book.Open('archive\1993-inventory.xls') <> 1 then
    begin
      Writeln('unreadable - quarantine for manual review');
      Exit;
    end;
    Sheet := Book.Sheets[1];          // Sheets[] 从 1 开始
    for R := Sheet.UsedRange.FirstRow + 1 to Sheet.UsedRange.LastRow + 1 do
      for C := Sheet.UsedRange.FirstCol + 1 to Sheet.UsedRange.LastCol + 1 do
      begin
        V := Sheet.Cells[R, C].Value;
        if not VarIsEmpty(V) then
          Writeln(Format('R%dC%d = %s', [R, C, VarToStr(V)]));
      end;
  finally
    Book.Free;
  end;
end;

请留意这段循环里的索引运算。UsedRange 的边界是从 0 开始的,而工作表集合和单元格访问都是从 1 开始的,这种不一致早于现有 API 就存在,出于兼容性被保留了下来。忘记这项调整会审计错整个矩形区域,而且不会报告任何异常。无需加载文件就能做的低成本预检查见 轻量级工作簿检视

你得不到什么,以及该怎么处理

格式不会被解释。HotXLS 不解析这几代的 XF 和 FONT 记录,因此字体、颜色、边框和数字格式都无法获得,曾被 Excel 显示为日期的单元格,读出来的是它们的原始序列号

最后这一点需要在你自己的代码中处理,而不是在读取器里处理,原因很坦诚:BIFF2 到 BIFF4 中的数字格式不足以支撑一个自动的日期判定。一列五位数的数字可能是日期,也可能是零件编号。请依据工作簿的日期系统有意识地做转换,其规则见 日期序列号、1904 系统与数字格式:

// 按列决定,而不是按值决定:一个五位数既可能是日期
// 也可能是零件编号,遗留格式信息无法告诉你答案
if ColumnHoldsDates(C) then
begin
  // 两套日期系统相差 1462 天,因此同一个序列号
  // 对应的两个日期会相差整整四年。应从工作簿本身
  // 读取所用的系统,而不是凭空假设
  if Book.Date1904 then
    Writeln(DateToStr(SerialToDate1904(V)))
  else
    Writeln(DateToStr(SerialToDate1900(V)));
end
else
  Writeln(VarToStr(V));

还有两处结构性说明能补全整幅图景。密码保护和代码页记录出现在单一的工作表流内部,而不是工作簿级别的流中,因为这类文件根本没有工作簿级别的流可用,所以必须在工作表上下文中识别它们。而且一份 BIFF2 到 BIFF4 文件只包含一个工作表子流;多工作表工作簿要等到格式获得容器之后才出现

因此,务实的迁移路径是两步走:先读取遗留文件取得其中的值,再写出一份现代工作簿,携带这些值,格式则由你自己重新应用。遗留文件读取、现代文件写入以及介于两者之间的一切,都运行在适用于 Delphi 和 C++Builder 的同一个库中,详见 HotXLS Delphi 电子表格组件页面