技术文章

Delphi 中重写 VBA 源码并重新压缩 MS-OVBA

如果要在一千个启用宏的报表模板中重命名硬编码的工作表引用,逐个文件打开 VBA 编辑器手工处理显然不可行。HotXLS 是原生 Delphi 和 C++Builder Excel 组件,它将 VBA 模块源码公开为可编辑的 SourceCode 属性,并使用 Microsoft 为 VBA 存储定义的 MS-OVBA 压缩算法重新压缩每次编辑,然后将结果写回传统 XLS VBA 存储、独立 VBA 项目文件或启用宏的 XLSM 工作簿。整个流程不需要 Excel 实例、VBA 编辑器或宏录制器

VBA 模块流为什么不是文本文件

XLS 工作簿或独立 VBA 项目文件中的 VBA 模块,并不是一段等待读取的流式源文本,而是一个小型二进制容器。开头是编译性能缓存,Office 在缓存仍与宿主版本匹配时会用它跳过模块重新编译,随后才是实际源码,这些源码经过 MS-OVBA 为 VBA 存储专门定义的专有压缩方案处理。这种方案不是 zip,不是 deflate,也不是 Windows 压缩 API 原生生成的任何格式,这正是大多数第三方 Excel 库可以读取模块源码却止步于写回的原因——解压是较容易的一半,而重新压缩才是一个细微错误就可能导致 Excel 拒绝打开文件的地方。关于读取端的公开说明确实存在,但真正执行重新压缩并实现写入,而不是只解包现有模块供检查的写入端实现仍然很少,因此它至今仍是 Excel 文件格式中记录最少的角落之一

HotXLS 的 SourceCode 属性究竟改变了什么

HotXLS 将每个 VBA 模块表示为 TXLSVBAModule 对象,并提供普通的 SourceCode: WideString 属性。为它赋予新值就像表面看起来一样简单:模块会在内存中标记为脏,而底层 OLE 流直到项目保存时才会被修改。项目本身来自传统 XLS 引擎的 IXLSWorkbook.VBAProject,或 OOXML 启用宏引擎的 TXLSXWorkbook.ParsedVBAProject,二者都会返回一个 TXLSVBAProject,其模块通过从 1 开始的 Item[] 索引器和 Count 属性提供访问,因此批量编辑工作簿中的每个模块只需遍历一个整数范围

var
  Wb: TXLSWorkbook;
  Project: TXLSVBAProject;
  I: Integer;
  Updated: WideString;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('MonthlyReport.xls');
    if Wb.HasVBAProject then
    begin
      Project := Wb.VBAProject;
      for I := 1 to Project.Count do
      begin
        Updated := StringReplace(Project[I].SourceCode,
          'ReportSheet2025', 'ReportSheet2026', [rfReplaceAll]);
        if Updated <> Project[I].SourceCode then
          Project[I].SourceCode := Updated;   // marks the module dirty
      end;
      Wb.SaveAs('MonthlyReport.xls');          // recompresses on write
    end;
  finally
    Wb.Free;
  end;
end;

这段循环同样适合审计流程。在批量处理一千个模板之前,大多数团队会先确认其中多少文件实际包含宏,以及这些宏引用了什么,这正是工作簿审计与转换工作台所面对的场景——这里驱动重写循环的同一个 Project.Count,在那里就会变成逐文件的宏计数

MS-OVBA 压缩容器内部结构

MS-OVBA 压缩格式会将源码字节打包进规范所称的 CompressedContainer:首先是一个必须等于 0x01 的单字节签名,后面是一系列 CompressedChunk 块,每块最多覆盖 4096 个解压后字节。16 位块头包含三个字段——必须等于 3 的 3 位签名、12 位大小字段,以及用于标记块负载是字面量字节还是令牌压缩序列的 CompressedChunkFlag 位。标志位被设置时,负载是一组组由标志字节作为前缀的八令牌序列,每个令牌要么是一个字面量字节,要么是 CopyToken,也就是指向同一块中更早解压字节的偏移量/长度回溯引用;偏移量和长度之间的位宽会根据解压器当前位于块中的位置而变化。MS-OVBA 的这一部分(§2.4.1,压缩与解压)最容易让手写实现因为位宽计算中的一个差一错误而浪费一天时间

HotXLS 为什么写入原始块而不是匹配令牌

HotXLS 的写入路径完全绕过了算法中的令牌匹配部分。重新压缩编辑后的模块时,每个块都将 CompressedChunkFlag 清零,表示块保存的是字面量字节而不是回溯引用令牌。MS-OVBA 允许这样做,因为压缩容器可以完全由未压缩块组成,同时它也正好移除了最难手工实现的部分:寻找有效回溯引用,并将偏移量/长度组合打包进取决于块内当前位置的位宽。代价体现在文件大小而不是正确性上——重写后的模块流大小接近源文本加上每个 4096 字节块两个字节的块头,不会像完全令牌压缩的块那样更小。所有实现了规范解压端的读取器,包括 Excel,都能正确打开结果,因为原始块与令牌压缩块一样,都是有效的 CompressedChunk

HotXLS 重写模块时保持不变的内容

重新压缩只会替换模块流的一部分。每个模块流都先存储性能缓存,再存储压缩源码,而项目的 dir 流会在 MODULEOFFSET 条目中准确记录每个模块的分界位置。HotXLS 读取该偏移量,原样保留此前的每个字节,只从该偏移量开始重建压缩容器

源码本身会按照 VBA 项目使用的代码页往返处理,而不是使用 UTF-8,也就是 Office 最初写入项目时使用的同一个旧式代码页。SourceCode 编辑如果引入了该代码页字符集之外的字符,HotXLS 在将字符串重新编码为字节时会静默替换为最佳匹配字符,而不会拒绝操作,因此在注释或字符串字面量中加入不常见的区域字符时,最容易观察到字符损失。同一项目中的外部引用和库绑定会沿着相关但独立的保留路径处理,详见VBA 外部链接保留配套文章;如果重写流程要处理链接到其他工作簿或类型库的项目,建议先阅读该文章

如何将重写后的宏写回工作簿

无需显式调用重新压缩步骤,它会在工作簿或独立 VBA 项目保存的瞬间自动运行。TXLSVBAProject.ApplyChanges 会遍历每个模块,重新压缩自上次保存以来 SourceCode 发生变化的模块,并只重写该模块的流;当保存目标保持原始文件格式时,传统的 TXLSWorkbook.SaveAs,以及用于启用宏 XLSM 包的 OOXML TXLSXWorkbook.SaveAs,都会在写入磁盘前在内部调用它;当目标是独立 VBA 项目文件而不是完整工作簿时,SaveVBAProjectToFile 也会调用同一个方法

var
  Wb: TXLSWorkbook;
begin
  Wb := TXLSWorkbook.Create;
  try
    if Wb.LoadVBAProjectFromFile('LegacyMacros.ole') = 1 then
    begin
      Wb.VBAProject[1].SourceCode :=
        StringReplace(Wb.VBAProject[1].SourceCode, 'OldServer', 'NewServer', [rfReplaceAll]);
      Wb.SaveVBAProjectToFile('LegacyMacros_Patched.ole');  // ApplyChanges runs internally
    end;
  finally
    Wb.Free;
  end;
end;
var
  Xlsx: TXLSXWorkbook;
  Project: TXLSVBAProject;
begin
  Xlsx := TXLSXWorkbook.Create;
  try
    Xlsx.Open('Dashboard.xlsm');
    Project := Xlsx.ParsedVBAProject;
    if Assigned(Project) then
    begin
      Project[1].SourceCode := StringReplace(Project[1].SourceCode,
        'ConnStringV1', 'ConnStringV2', [rfReplaceAll]);
      Xlsx.SaveAs('Dashboard.xlsm');   // SyncParsedVBAProject recompresses before the part is written
    end;
  finally
    Xlsx.Free;
  end;
end;

这三个目标在底层共享同一套 SourceCodeApplyChanges 机制,真正的区别只有最终由哪个保存调用触发重新压缩

仍可能失败的情况

在对生产文件运行重写流程之前,有两种失败模式常见到值得提前规划。经过数字签名的 VBA 项目在源码发生变化的瞬间就不再保持有效签名,因为签名覆盖了项目内容;HotXLS 无法代替用户重新签名,文件下次打开时 Excel 会删除或标记该签名,因此如果工作流程确实会检查签名,经过签名的宏项目还需要在后续步骤中重新签名。第二种失败模式属于那些想从头重写这种压缩格式,而不是使用已有可靠实现的开发者:块头、签名半字节、大小字段或压缩标志中的一个错误位,就会生成 Excel 拒绝打开的文件,通常只显示没有指出错误字节位置的通用损坏警告,这正是前文所述原始块写入策略要避免的问题

使用该功能并不需要逆向工程这种格式。Delphi 和 C++Builder 开发者可以获得 SourceCode 的读写访问、符合 MS-OVBA 的重新压缩能力,以及本文介绍的三个写回目标,这些都属于标准HotXLS 组件的一部分,并与其传统 XLS 和 OOXML 工作簿 API 的其他能力一起提供