Exit code: 0 Wall time: 1.2 seconds Output: Exit code: 0 Wall time: 0.7 seconds Output: Exit code: 0 Wall time: 1.1 seconds Output: Delphi 中重寫 VBA 原始碼並重新壓縮 MS-OVBA

技術文章

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 的其他能力一起提供