技术文章

用 HotXLS 进行仅元数据与选择性的 XLSX 加载

“这份工作簿还定义着叫 TaxRate 的名称吗?”这个问题由 XLSX 包里一小块就能回答。工作表名称、它们的隐藏或深度隐藏状态、所有工作簿级定义名称都住在 xl/workbook.xml(ECMA-376 Part 1, §18.2)里,通常几 KB。单元格住在别处——每个工作表一个 xl/worksheets/sheetN.xml 分部加一张共享字符串表,几乎所有字节都在那里。一个坚持先构建完整工作簿才许你提问的加载器,会为了回答前几 KB 早已定案的问题,把文件夹里每个文件的这一切都解压并解析一遍

要紧的分界在于:读工作簿的形状,和读它的重量。一个能在形状读完就停下的加载器,可以为选定的工作表子集加载重量、之后再补取一张工作表,把“打开工作簿”从一步定胜负变成每次调用自己定范围的操作。它也带来两个陷阱:一个关于索引基,一个关于源文件必须在原地待多久

仅元数据的 Open 读什么,停在哪里

在 Open 之前把 TXLSXWorkbook.MetadataOnly 设为 True,HotXLS 解析工作簿分部、样式、主题、定义名称、工作表名称和可见性状态、连接、外部链接,以及来自 docProps/core.xml 和 docProps/app.xml 的文档属性。然后跳过每个工作表的单元格 XML 和每表卫星分部:批注、绘图和表。共享字符串表也一并跳过——这一点比听上去重要,因为文本密集的工作簿里那一个分部的体量能与工作表们打平

返回的工作簿结构完整。Sheets.Count 正确,每张表有真名和 Visibility,DefinedNames 已填充,ExternalLinks 列出每个链接目标,Title、Author 和 LastModifiedBy 带着文件声明的内容。每张表只是零个单元格

HotXLS MetadataOnly 在 Delphi XLSX 归档中读取工作簿属性、工作表名称、样式和链接,然后在任何工作表单元格 XML 之前停下
MetadataOnly 读取归档的每个结构部分,在工作表单元格 XML 之前停下。返回的 Delphi 工作簿带有表名、可见性、定义名称和样式,单元格数量为零

这正是审计要跑的数据。哪些工作簿藏了工作表?哪些还定义着 TaxRate、指向什么?哪些链向去年已退役的文件共享?下面的扫描器对一个文件回答全部三问。注意并排的两种索引约定:Sheets[] 从 1 开始,与 XLS 引擎一致,而 DefinedNames 和 ExternalLinks 是普通的从 0 开始的列表

procedure ScanWorkbook(const FileName: WideString; Report: TStrings);
var
  Workbook: TXLSXWorkbook;
  Name: TXLSXDefinedName;
  I: Integer;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    Workbook.MetadataOnly := True;           // Open 时读取,所以要先设置
    if Workbook.Open(FileName) <> 1 then
    begin
      Report.Add(FileName + ': open failed');
      Exit;
    end;
    for I := 1 to Workbook.Sheets.Count do   // Sheets[] 从 1 开始
      case Workbook.Sheets[I].Visibility of
        xlsxSheetHidden:
          Report.Add(FileName + ': hidden sheet ' + Workbook.Sheets[I].Name);
        xlsxSheetVeryHidden:
          Report.Add(FileName + ': very hidden sheet ' + Workbook.Sheets[I].Name);
      end;
    Name := Workbook.DefinedNames.FindByName('TaxRate');
    if Name <> nil then
      Report.Add(FileName + ': TaxRate = ' + Name.Formula);
    for I := 0 to Workbook.ExternalLinks.Count - 1 do   // 从 0 开始的列表
      Report.Add(FileName + ': links to ' + Workbook.ExternalLinks[I].Target);
  finally
    Workbook.Free;
  end;
end;

扫描器特意读 Visibility 而不是布尔值 Visible。两种隐藏状态下 Visible 都是 False,而深度隐藏的工作表恰是审计最想标记的——Excel 的取消隐藏对话框不列出它,只有 VBA 或直接改文件才能把它请回来

用 SelectedSheets 从四十张表里加载两张

SelectedSheets 是工作簿持有的一个 TStringList。列表里至少有一个名字时,Open 只为它点名的表解析单元格 XML;其他每张表都带着正确的名称和可见性创建、没有单元格。空列表是默认值,加载全部工作表。名字用列表自己的 IndexOf 查找,所以默认设置下匹配忽略大小写——这与 Excel 的工作簿内两张表名不能仅大小写不同的规则正好对上

Workbook := TXLSXWorkbook.Create;
Workbook.SelectedSheets.Add('Summary');
Workbook.SelectedSheets.Add('Q3 Detail');
if Workbook.Open('consolidated-2026.xlsx') = 1 then
  ShowSheet(Workbook.Sheets[1]);   // ShowSheet 是你自己的 UI 代码

两个开关只朝一个方向相互作用。MetadataOnly = True 压过选择、跳过所有表,所以同时配置两者的工作簿回来时处处无单元格,选择看起来坏了,其实是被压了。每次 Open 用一个:发现用仅元数据,定向干活用选择

ParallelParse 打开时,选择性加载同样贯穿并行解析阶段;未选中的表在 worker 作业内部被跳过,线程把时间花在你点名的表上。源是二进制 .xlsb 工作簿时这两个属性同样适用,但有一条范围规则:带 VBA 工程的 .xlsb 必须不用这两个选项打开;任一设置后,Open 会报告选择性读取不受支持

HotXLS SelectedSheets 在 Delphi 中为四十张表里两张点名的表解析单元格,LoadSheet 则从未变的源文件实体化一张被跳过的表
SelectedSheets 只为点名的表解析单元格 XML,其余留作保留名称与可见性的空壳。LoadSheet 稍后实体化某个空壳,前提是源文件还在磁盘上且未变

为什么 LoadSheet(1) 加载了错误的工作表?

因为 LoadSheet 收从 0 起的位置,而 Sheets[] 从 1 起。LoadSheet(1) 实体化的是工作簿里的第二张表;又由于加载已有单元格的表是不动声色的空操作并返回 True,一个搞混的索引可以看着像成功,而用户点的那张表依然空着。这个换算在你的代码里只该存在于一个地方

第二个陷阱是文件寿命。LoadSheet 不在两次调用之间保持归档打开。它按名字重新打开原始文件,解析工作簿分部、样式和共享字符串,然后解析请求的工作表。信任这个文件之前,HotXLS 会把它的大小和最后写入时间与 Open 时捕获的值比较;若期间有别的进程保存覆盖了工作簿,LoadSheet 返回 False,而不是把另一个版本的单元格混进你已持有的模型

procedure TReportForm.ShowSheetNo(SheetNo: Integer);  // SheetNo 从 1 起,与 Sheets[] 一致
var
  Sheet: TXLSXWorksheet;
begin
  Sheet := FWorkbook.Sheets[SheetNo];
  if Sheet.Cells.Count = 0 then
    if not FWorkbook.LoadSheet(SheetNo - 1) then      // LoadSheet 要的是从 0 起的位置
    begin
      StatusBar.SimpleText := 'The workbook changed on disk; reopen it';
      Exit;
    end;
  Grid.ShowSheet(Sheet);                              // 你自己的表格代码
end;

这套设计带来几条推论。从 TStream 打开的工作簿没有源文件可回,所以对流打开的 .xlsx,LoadSheet 返回 False;加密工作簿同理——它先在内存里解密再解析。从流选择性打开的二进制 .xlsb 是例外,HotXLS 会为延后的表暂存一份流的临时副本。而真正空着的表每次调用都会被重读,因为零单元格也是被跳过的表的样子

成本模型决定怎么用。每次 LoadSheet 都重解析共享字符串表,所以一张一张实体化十张表要为那个分部付十次钱。对用户偶尔多开一个标签页的查看器,这价钱可以接受。已经知道表集合时,改用 SelectedSheets 重开:一次归档遍历,一次共享字符串解析。内容上也有差别:LoadSheet 只带来表本身的单元格、值、公式和格式,而批注、绘图和表随 Open 一起来——所以你的代码要读其批注或表的那些工作表,应该一开始就放进选择

一个有意重新打开的发现流水线

全量重生成的保存会重写从未变过的字节。解压主题、构建对象模型、再序列化、再压缩,结果与原件不同——尽管没人碰过主题。对 VBA 工程和数据透视缓存,代价不止是嘈杂的 diff:VBA 工程是带内部流的二进制复合文件,数据透视缓存持有源数据快照、透视表定义按位置引用其字段,所以没人改它们时,最稳妥的做法是别动它们的字节

PreserveRawParts 默认 True,让 SaveAs 直接从源归档拷贝未变分部的压缩字节。覆盖范围:主题、VBA 工程、连接、图表、嵌入媒体、打印机设置、数据透视表、数据透视缓存定义与记录——每个都只在模型声明它未被修改时走这条路。这些分部途中从不解压也从不重压。把属性设为 False 强制每个分部完整重序列化——想让输出只反映对象模型所含内容时,伸手摸这个开关

原始拷贝路径用与 LoadSheet 相同的文件检查:源必须仍以 Open 时见到的大小和时间戳存在。不满足时,SaveAs 正常序列化那些分部,文件依然完整。两种情况总是走序列化路径:源是二进制 .xlsb 的工作簿(读取时已转成 XML),以及设置 StrictOOXML 的保存(分部命名空间变了)

保存部分加载的工作簿需要一个决定。对 .xlsb 源,只要还有表被延后,SaveAs 就拒绝,并在 Diagnostics 里记下“保存前先加载所有被延后的二进制工作表”。对 .xlsx,把选择性加载的工作簿当只读视图对待:壳表没有单元格可供写出,所以要么先对每个壳调 LoadSheet,要么编辑保存之前不带选择重新打开

这与其他大文件杠杆的关系

站得住的模式是两遍。第一遍对每个文件仅元数据打开并做判断;第二遍只重开命中的文件,带工作表选择,干活。重开看似浪费,直到你数一数第一遍省下了什么:第二次 Open 是唯一解析单元格的一次,而且只解析第一遍挑中的表

Delphi 中 HotXLS 的两遍发现管道:仅元数据的一遍决定哪些工作簿和工作表重要,随后定向的一遍只解析选中的工作表
第一遍仅以元数据扫描目录,判断哪些文件和表要紧;第二遍带表选择重新打开命中的文件。只有第二次打开才解析单元格 XML
procedure ProcessInvoiceFile(const FileName: WideString);
var
  Probe, Workbook: TXLSXWorkbook;
  Wanted: TStringList;
  I: Integer;
begin
  Wanted := TStringList.Create;
  try
    // 第一遍:发现,不碰单元格
    Probe := TXLSXWorkbook.Create;
    try
      Probe.MetadataOnly := True;
      if Probe.Open(FileName) <> 1 then
        Exit;
      for I := 1 to Probe.Sheets.Count do
        if Pos('Invoice', Probe.Sheets[I].Name) > 0 then
          Wanted.Add(Probe.Sheets[I].Name);
    finally
      Probe.Free;
    end;
    if Wanted.Count = 0 then
      Exit;                               // 这个文件里没有值得解析的东西

    // 第二遍:只为选中的工作表解析单元格
    Workbook := TXLSXWorkbook.Create;
    try
      Workbook.SelectedSheets.Assign(Wanted);
      if Workbook.Open(FileName) = 1 then
        PostInvoices(Workbook);           // 你自己的处理代码
    finally
      Workbook.Free;
    end;
  finally
    Wanted.Free;
  end;
end;

Open 之后清掉 MetadataOnly 不会把探测对象变成完整工作簿。这个标志在打开时读取;之后改它只影响该对象的下一次 Open。探测对象仍可经 LoadSheet 一张一张长,一两张时还算合理,但流水线更适合干净的重开——顺带把每张表的批注和表也一起带来

为什么一次往返会改动你从没碰过的分部

选择性加载削减的是你解析的东西。表一旦加载,它的成本它一点也不分担;作业需要每张表的每个单元格时它也帮不上。那种情形关于存储——见行块单元格存储与区间样式叠加一文——和解析吞吐——见并行 XLSX 解析与内存分配器。任务是完全只读、编辑都不碰时,流式直接读取器整个跳过工作簿对象模型,比任何构建模型的加载策略都更合适

这一切都跑在你自己的 Delphi 或 C++Builder 可执行文件里,做扫描的服务器上不需要安装 Excel。支持的格式、版本与许可列在HotXLS Delphi 电子表格组件页面