技术文章

在 Delphi 中读写 XLSB 二进制工作簿

HotXLS 从 Delphi 读写 Excel Binary Workbook(XLSB)文件,走的是与 XLSX 相同的 TXLSXWorkbook 门面:Open 从包内容自动识别二进制工作簿,SaveAs 或带 xlsxExcelBinaryWorkbook 的 SaveAsChecked 写出二进制工作簿。后端覆盖一个经过验证的子集,无法表示的内容会在碰目标文件之前被拒绝

最后这半句值得慢读。XLSB 看着像 XLSX 的简单变体,就打包而言也确实是。麻烦藏在两个格式编码方式不同的细节里,藏在库把一个桥接成另一个时默默做的假设里。这个后端里最有教育意义的 bug 压根不是畸形记录,而是一个失踪的默认字体——值、格式代码、列宽全都正确,日期列在 Excel 里却显示成 ####

XLSB 文件是什么,与 XLSX 有何不同

XLSB 文件是一个 OPC 包,部件结构与 XLSX 工作簿完全一致,只是工作簿、工作表、样式和共享字符串部件存的是二进制记录而非 XML。格式在 [MS-XLSB] 里规范,记录编码常被称为 BIFF12。[Content_Types].xml、关系部件和 ZIP 容器都还在;变的是 xl/workbook.bin 取代 xl/workbook.xml,xl/worksheets/sheet1.bin 取代工作表 XML,以此类推

每条 BIFF12 记录以一个紧凑的变长头部开始。记录类型占一或两个字节,每字节 7 位、最高位作续传标志,所以记录类型装得进 14 位。载荷大小跟在后面,用同样的 7 位方案占一到四个字节。字符串是 32 位字符数加 UTF-16 码元。与 BIFF8 相比,这个布局干净得多:没有 8224 字节的 CONTINUE 拆分,记录中间也没有压缩与宽字符标志之分。与 XLSX 相比,它完全跳过 XML 分词,这正是大 XLSB 文件在磁盘上更小、Excel 打开更快的原因

有一个打包细节坑检测代码。Excel 注册 XLSB 工作簿部件用的内容类型是 application/vnd.ms-excel.sheet.binary.macroEnabled.main,哪怕工作簿根本没有宏。HotXLS 先在 [Content_Types].xml 里查这个注册,内容类型缺席时才回退到 .bin 部件扩展名,而且两条路都还要通过严格的记录验证器。部件名、关系和内容类型的通用规则,与Excel 为何修复一个合法 XLSX 及其背后的 OPC 包规则里讲的是同一套

什么时候该选 XLSB 而不是 XLSX

下游系统要求 XLSB,或者你要和在意打开速度的 Excel 用户交换大体积、数值密集的工作簿时选 XLSB;否则 XLSX 仍是最稳的默认。XLSX 是 ISO 标准(ISO/IEC 29500),工具支持广泛,用 ZIP 工具加文本编辑器就能查看,所有表格产品都读它。XLSB 是微软格式,有公开规范,生态窄一些

实践中需求来自两个方向。一些报表管线和归档多年前就标准化在 .xlsb 上,别的格式一律拒收。另一些团队生成几十万数值单元格的工作簿,发现用户打开二进制版本明显更快。两个都是正当理由。“听起来更高效”不是——HotXLS 的二进制后端会把整个包暂存在内存里,所以它不是内存受限服务器导出的工具,那种场景该走流式 XLSX 写入器

用 TXLSXWorkbook 打开与保存 XLSB

TXLSXWorkbook.Open 不需要格式提示:同一个调用加载 XLSX、XLSM 或 XLSB,二进制加载成功后 TXLSXWorkbook.SourceFormat 报告 xlsxExcelBinaryWorkbook。写入是显式的。不带格式的 SaveAs(FileName) 永远产出 Open XML 工作簿,哪怕文件名以 .xlsb 结尾,所以二进制输出必须显式指名格式

uses
  SysUtils, lxHandleX;

procedure ConvertToXlsb(const Source, Target: string);
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    // Open 从包本身识别 XLSX、XLSM 或 XLSB
    if Book.Open(Source) <> 1 then
      raise Exception.Create('Input is unsupported or invalid');

    // 只靠扩展名永远选不中二进制输出
    if Book.SaveAsChecked(Target, xlsxExcelBinaryWorkbook) <> 1 then
      raise Exception.Create('XLSB output is unsupported or failed');
  finally
    Book.Free;
  end;
end;

流输出走 SaveAs(Stream, xlsxExcelBinaryWorkbook) 或 SaveAsChecked 的流重载。内部两者都路由到私有的 SaveAsXlsbStream 和 SaveAsXlsbFile 方法,你不会直接调它们。文件输出先暂存到同目录的临时文件,最后一次替换提交。流目标必须可读且可定位,因为写入器会快照它的字节、大小和位置,最终拷贝失败时还原。TXLSXWorkbook.LastSaveUsedStreaming 在二进制保存后报告 False,这很准确:第一个字节到达目标之前,整个包都已在内存里

为什么是验证过的子集而不是完全保真

HotXLS 不为 XLSB 维护第二套对象模型。打开时,XlsbToOpenXml 把二进制包转换成内存中的 Open XML 包,交给普通 XML 解析器加载;保存时,XML 写入器构建包,OpenXmlToXlsb 把它编码成二进制记录。于是每个特性都得扛过两次翻译,桥无法精确翻译的特性一律拒绝,绝不含糊近似

截至 HotXLS 2.384.113,支持的集合对大多数数据工作簿已经够宽:带类型的值、共享与内联字符串、常见字体、填充、边框和数字格式、行高列宽、带缓存结果的普通单元格公式、定义名称、合并单元格、超链接、冻结与拆分窗格、带简单自定义条件的 AutoFilter 范围,以及普通工作表表格。集合之外的是:共享公式与数组公式、未来函数与用户自定义函数、外部工作簿引用、图表与绘图、PivotTable 与切片器、VBA、条件格式、数据验证,以及手动计算或迭代这类非默认计算设置

拒绝是强制的,不是建议。TXLSXWorkbook.GetConversionReport(xlsConversionExcelBinaryWorkbook) 会在保存前列出不支持的特性,而且就算给了 AAllowLossy = True 或用不检查的 SaveAs,这些条目照样让保存失败。这是刻意的。悄悄从二进制副本里丢掉一个 PivotTable,正是那种等工作簿摆到客户面前才有人发现的损失

uses
  Classes, lxHandleX, lxDiagnostics;

function CanSaveAsXlsb(Book: TXLSXWorkbook; Log: TStrings): Boolean;
var
  Report: TXLSConversionReport;
  I: Integer;
begin
  Report := Book.GetConversionReport(xlsConversionExcelBinaryWorkbook);
  try
    Result := True;
    for I := 0 to Report.Count - 1 do
      if Report[I].Fidelity = xlsConversionUnsupported then
      begin
        Result := False;
        Log.Add(Report[I].SheetName + ' ' + Report[I].Location + ': ' +
          Report[I].Feature + ' - ' + Report[I].Message);
      end;
  finally
    Report.Free;   // 报告归调用方所有
  end;
end;

报告覆盖的是活动模型。最终包验证仍是模型看不见之物的最后一道闸,比如源文件里的畸形记录或不认识的属性,所以干净报告之后保存仍可能失败,要以保存结果为准

默认字体和 0.00 格式为什么会消失

默认字体和内置数字格式消失,是因为 XML 样式读取器做了两个假设——它们对 HotXLS 自己写出的文件成立,对 Excel 写出的工作簿不成立。桥对两种资源都保存得完好无损;损失发生在上一层,也就是公开的 Open 路径里。这个差距是整个后端最大的教训:格式桥正确,不等于公开往返忠实

第一个假设关于 fonts[0]。HotXLS 保存 XLSX 时会往 0 号槽写一个隐式默认字体,它的 XML 读取器跳过那个槽,让面向用户的 Fonts 集合从索引 0 开始。Excel 创作的 XLSB 在那个槽里带自己的显式默认字体,一跳过就把这个选择扔了。第二个假设关于数字格式。读取器只把自定义 numFmtId 值(164 及以上)映射进 NumberFormats;内置 id 保持为 0。一个带内置格式 2(即 0.00)的带样式空单元格回来就成了 General

修复完全落在二进制导入路径里。整个包通过预检之后,XLSB 打开仅为这次同步 XML 解析设置一个私有样式模式。该模式下,读取器加载包括 0 号槽在内的每个字体并把每个 fontId 平移一位,把每个受支持的内置 id 转成模型里显式的格式代码,日期检测仍用原始 numFmtId,所以内置日期格式保住日期语义。finally 块保证清除该模式,之后的普通 XLSX 或 ODS Open 也会清掉保留的默认字体状态

fonts[0] 如何影响 Excel 列宽

Excel 依据工作簿默认字体(fonts[0])计算列宽,而不是依据该列单元格实际应用的字体。列 width 以默认字体最大数字宽度的字符单位存储,所以改 fonts[0] 会改变每一列的像素宽度,包括单元格全用了别的字体的列

这产出了整个排查里最迷惑人的症状。重新保存的工作簿单元格值相同、格式代码相同、width 属性相同,单元格字体和 Normal 样式都指向被保留的字体,Excel 却仍把一列日期画成 ####。写出的 fonts[0] 退回了 Calibri 11,最大数字宽度缩小,日期装不下了。HotXLS 现在保留源默认字体并原样写回 fonts[0],只有从未有过二进制源的工作簿才回退到 Calibri 11。完整的列宽算术见Delphi 中的 Excel 列宽与最大数字宽度(MDW)

依赖区域设置的内置格式一律拒绝,绝不猜测

没有显式格式代码的内置数字格式 id,只有含义固定时才被接受;依赖区域设置的 id 在包预检时拒绝。HotXLS 把 id 0 到 22、37 到 40 和 45 到 49 映射到 0.00、0%、mm:ss 和 @ 这类已知代码。164 以下其余的 id 属于渲染随 Excel 安装的区域设置而变的格式,导入用这样一句话拒绝它们:“依赖区域设置的数字格式需要显式格式代码”

替这些 id 编一个代码比失败更糟。猜出来的东亚日期模式看着合理,往返不报错,却给一半的用户显示错误的东西。时机也重要:这个检查跑在整包预检里、现有工作簿被清空之前,所以被拒的文件留下先前加载的模型、文件名和源格式,一动不动。Excel 日期序列值与 numFmt 文章讲了内置日期 id 如何与 1900 和 1904 日期系统交互

procedure LoadBinaryWorkbook(Book: TXLSXWorkbook; const FileName: string);
var
  I: Integer;
begin
  // 二进制输入需要急切的全量加载;选择性模式会直接拒绝
  Book.MetadataOnly := False;
  Book.SelectedSheets.Clear;

  if Book.Open(FileName) <> 1 then
  begin
    for I := 0 to Book.Diagnostics.Count - 1 do
      Writeln(Book.Diagnostics[I].Message);
    Exit;   // 先前加载的工作簿仍完好
  end;

  // 公式输出需要缓存,除非保存时重算
  Book.RecalcBeforeSave := xrmAlways;
end;

生产代码里使用 XLSB 的实用规则

把二进制后端的拒绝当成数据、而不是当成要压制的错误,它就可以放心依赖。由上面的行为可以推出几条规则

  • 每次二进制保存都显式指名格式;只给 .xlsb 扩展名产出的仍是 XLSX
  • 保存用户编辑过的工作簿前先跑 GetConversionReport(xlsConversionExcelBinaryWorkbook),把不支持的特性摆到用户面前
  • 二进制输入保持 MetadataOnly 关闭、SelectedSheets 为空,因为惰性加载将要求保留转换后的归档
  • 给公式备好缓存结果或在保存前重算,因为缺缓存在 xrmManual 和干净的 xrmIfDirty 下都会被拒
  • 为完整转换包加二进制输出预留内存预算;内存必须受限时用流式 XLSX

测试时,请通过公开 API 与 Excel 对照,而不是在桥的层面。默认字体和 0.00 两个回归都通过了桥测试,而它们都在一份 Excel 创作的工作簿经 Open 和 SaveAsChecked 打开再保存时立刻现形

FAQ

HotXLS 会自动识别 XLSB 文件吗

会。TXLSXWorkbook.Open 检查工作簿部件注册的内容类型并验证二进制记录,同一个调用加载 XLSX 和 XLSB,SourceFormat 报告读到的究竟是哪个

为什么带 .xlsb 文件名的 SaveAs 仍写出 XLSX

HotXLS 从不根据扩展名推断输出格式。要写二进制工作簿,请给 SaveAs 或 SaveAsChecked 传 xlsxExcelBinaryWorkbook

工作簿用了 XLSB 子集之外的特性会怎样

保存会在目标文件变动之前失败,就算允许有损保存也一样。GetConversionReport 事先列出阻塞的特性,Diagnostics 事后解释失败原因

XLSB 支持、转换报告以及其余工作簿 API 都随 HotXLS, the native Delphi and C++Builder spreadsheet component 提供