技術記事

DelphiでVBAソースを書き換えMS-OVBAを再圧縮する

1000枚のマクロ有効レポートテンプレートにまたがってハードコードされたワークシート参照をリネームすることは、各ファイルをVBAエディタで手作業で開くという選択肢を排除する。DelphiおよびC++Builder用ネイティブExcelコンポーネントであるHotXLSは、VBAモジュールのソースを編集可能なSourceCodeプロパティとして公開し、マイクロソフトがVBAストレージ用に定義するMS-OVBA圧縮アルゴリズムであらゆる編集を再圧縮し、その結果を従来のXLS VBAストレージ、スタンドアロンのVBAプロジェクトファイル、またはマクロ有効なXLSMワークブックへ書き戻すことでこのケースを扱う。この経路のどこにもExcelのインスタンスもVBAエディタもマクロレコーダーも関わらない

なぜVBAモジュールのストリームはテキストファイルではないのか

XLSワークブックまたはスタンドアロンのVBAプロジェクトファイルの内部にあるVBAモジュールは、読まれるのを待ってストリームに座っているソーステキストではない——それは小さなバイナリコンテナである。まずコンパイル済みのパフォーマンスキャッシュが来る。これはキャッシュがまだホストバージョンと一致する場合にOfficeがロード時にモジュールの再コンパイルをスキップするために使うバイト列であり、その後に実際のソーステキストが続き、MS-OVBAがVBAストレージ専用に定義する独自の圧縮方式を通される。その方式はzipでもdeflateでもなく、Windowsの圧縮APIがネイティブに生成するものでもない。これがまさに、ほとんどのサードパーティ製Excelライブラリがモジュールのソースを読める理由である——展開は問題のより簡単な半分である——一方でそれを書き戻すことには手を出さない、なぜなら再圧縮こそが、微妙に間違った1ビットがExcelが開くことを拒否するファイルを生む場所だからだ。読み取り側についての公開された解説は存在する。しかし既存のモジュールを検査のためだけに展開するのではなく、実際に再圧縮を行う書き込み側の実装は十分に稀であり、これはExcelファイル形式の中で最も文書化されていない片隅の一つであり続けている

HotXLSのSourceCodeプロパティは実際に何を変えるのか

HotXLSはすべてのVBAモジュールを単純なSourceCode: WideStringプロパティを持つTXLSVBAModuleオブジェクトとして表現し、それに新しい値を代入することは見た目通りに単純である:モジュールはメモリ上でダーティとしてマークされ、プロジェクトが保存されるまで基礎となる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;

そのループは監査パスの形でもある。1000枚のテンプレートに手を付ける前に、ほとんどのチームはまず、そのうち実際にマクロを持っているのはいくつで、それらのマクロが何を参照しているかを知りたがる。これはワークブック監査・変換ワークベンチの背後にあるシナリオである——ここで書き換えループを駆動するのと同じProject.Countが、そこではファイルごとのマクロ集計になる

MS-OVBA圧縮コンテナの内部

MS-OVBAの圧縮形式は、ソースバイトを仕様がCompressedContainerと呼ぶものにパッケージ化する:0x01と等しくなければならない単一のシグネチャバイトの後に、それぞれ最大4096バイトの展開済みデータをカバーするCompressedChunkブロックの並びが続く。16ビットのチャンクヘッダーは3つのフィールドを運ぶ——3と等しくなければならない3ビットのシグネチャ、12ビットのサイズフィールド、そしてチャンクのペイロードがリテラルバイトかトークン圧縮されたシーケンスかを示すCompressedChunkFlagビットである。フラグが設定されている場合、ペイロードはフラグバイトが前置された8トークンずつのグループの連なりであり、各トークンは単一のリテラルバイトか、CopyTokenのいずれかである:CopyTokenは、同じチャンク内ですでに展開されたバイトへのオフセット・長さの後方参照であり、オフセットと長さの間のビット幅の分割は、展開器が現在チャンクのどこまで進んでいるかによって変化する。MS-OVBAのこの部分(§2.4.1、圧縮と展開)こそが、自前の実装がそのビット幅計算での1つずれのエラーに最も頻繁に1日を失う場所である

なぜHotXLSはトークンマッチングではなく生のチャンクを書くのか

HotXLSの書き込み経路は、そのアルゴリズムのトークンマッチングの半分を完全に迂回する。編集されたモジュールを再圧縮する際、すべてのチャンクはCompressedChunkFlagがクリアされた状態で出力される。つまりチャンクは後方参照トークンではなくリテラルバイトを保持する——これはMS-OVBAの下で合法である、なぜなら圧縮コンテナは完全に非圧縮チャンクだけで構成されることが許されているからだ。そしてこれは、アルゴリズムの中で手作業で正しく行うのが最も難しい部分——有効な後方参照を見つけ、オフセット・長さのペアをチャンク内の現在位置に依存するビット幅に詰め込むこと——を正確に取り除く。このトレードオフはファイルサイズに現れ、正しさには現れない——書き換えられたモジュールストリームは、完全にトークン圧縮されたチャンクほど小さくはならず、そのソーステキストのサイズに4096バイトブロックごとの2バイトヘッダーを加えたものに近くなる。仕様の展開側を実装するあらゆるリーダー、Excelを含めて、それでも結果を正しく開く。なぜなら生のチャンクはトークン圧縮されたものと同じくらい有効なCompressedChunkだからだ

HotXLSがモジュールを書き換える際に触れずに残すもの

再圧縮はモジュールストリームの一部だけを置き換える。すべてのモジュールストリームはまずそのパフォーマンスキャッシュを保存し、次に圧縮されたソースを保存する。そしてプロジェクトのdirストリームは、各モジュールについてその分割点がどこに落ちるかを正確にMODULEOFFSETエントリに記録する。HotXLSはそのオフセットを読み取り、それより前のすべてのバイトを見つけたとおりに正確に保持し、圧縮コンテナだけをそのオフセット以降から再構築する

ソーステキスト自体はUTF-8ではなくVBAプロジェクト自身のコードページを通じて往復する——これはOfficeが最初にそのプロジェクトを書いたのと同じレガシーなコードページである。そのコードページのレパートリー外の文字を導入するSourceCodeの編集は、HotXLSがその文字列をバイトへ再エンコードする際に、拒否されるのではなくベストフィット置換文字へ静かに置き換えられる。そのため、コメントや文字列リテラルに落とし込まれた珍しい地域固有の文字が、この損失に最も気づきやすい場所である。同じプロジェクト内の外部参照とライブラリバインディングは、関連するが別個の保存経路をたどり、VBA外部リンク保存に関する関連記事で扱われている。他のワークブックや型ライブラリにリンクするプロジェクトに書き換えパスを実行する前に読む価値がある

書き換えたマクロをワークブックに戻すにはどうすればよいか

再圧縮のステップを明示的に呼ぶものは何もない——ワークブックやスタンドアロンのVBAプロジェクトが保存された瞬間に自動的に実行される。TXLSVBAProject.ApplyChangesはすべてのモジュールを歩き、最後の保存以降にSourceCodeが変わったものだけを再圧縮し、そのモジュールのストリームだけを書き換える。従来のTXLSWorkbook.SaveAsは、保存先がファイルの元の形式を保っている場合、そして OOXML のTXLSXWorkbook.SaveAsはマクロ有効なXLSMパッケージの場合、どちらもディスクに何かが書き込まれる前に内部でそれを呼び出す。そしてSaveVBAProjectToFileは、ターゲットが完全なワークブックではなく単独のVBAプロジェクトファイルである場合に同じメソッドを呼び出す

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;

この3つの出力先はすべて、その下に同じSourceCodeApplyChangesの仕組みを共有している。それらの間の唯一の本当の違いは、どの保存呼び出しが最終的に再圧縮を引き起こすかである

それでも壊れるところ

本番ファイルに対して書き換えパスを実行する前に計画しておく価値があるほどよくある失敗モードが2つある。デジタル署名されたVBAプロジェクトは、そのソースが変わった瞬間に有効な署名ではなくなる、なぜなら署名はプロジェクトの内容をカバーしているからだ。HotXLSにはあなたの代わりにプロジェクトに再署名する方法がなく、Excelは次にファイルが開かれたときにその署名を削除するかフラグを立てる。そのため、署名済みのマクロプロジェクトは、その署名があなたのワークフローが実際にチェックするものであるなら、下流で再署名のステップを必要とする。2つ目の失敗モードは、この圧縮形式をすでにそれを扱っているライブラリを使う代わりにゼロから再実装しようとする誘惑にかられた人のためのものである:チャンクヘッダーの中の、シグネチャのニブル、サイズフィールド、あるいは圧縮フラグにおけるたった1つの間違ったビットは、Excelが開くことを拒否するファイルを生む。通常はどのバイトが間違っていたかのヒントを何も与えない汎用の破損警告の背後で、である——これはまさに先に説明した生チャンク書き込み戦略が避けるために存在するバグのクラスである

これらのいずれも、使用するためにこの形式をリバースエンジニアリングする必要はない。DelphiとC++Builderの開発者は、ここで説明したSourceCodeの読み書きアクセス、MS-OVBA準拠の再圧縮、そして3つの書き戻し先すべてを、その従来のXLSとOOXMLワークブックAPIの残りとともに、標準のHotXLSコンポーネントの一部として手に入れる