【テクニカル・上級編】Wordの『ストーリー』を理解する:ヘッダー・フッター・本文を横断する一括置換 – Word VBA解析バイブル

スポンサーリンク

Word VBAにおける「ストーリー」の完全掌握:ヘッダー・フッター・本文・テキストボックスを網羅する漏れなき一括置換アーキテクチャ

Excel VBAの2次元グリッドモデルに慣れ親しんだエンジニアがWord VBAの開発に参入した際、最初にぶち当たる巨大な壁が「画面に見えている文字列が、単一のテキストストリームとして存在していない」という事実である。

エンタープライズ領域における文書自動生成、あるいは数万ファイルにおよぶレガシー契約書のシステム一括移行プロジェクトにおいて、「ヘッダーの会社名が置換されていない」「描画オブジェクト内のテキストが漏れている」「特定セクションのフッターだけ変更が適用されていない」といった障害は日常茶飯事である。

本稿では、Wordオブジェクトモデルの深層に存在する「ストーリー(Story)」の内部構造を解剖し、Windows APIを用いた描画パイプラインの制御、COMオブジェクトのメモリライフサイクル管理、そしてすべてのストーリーを漏れなく走査する全自動一括置換アーキテクチャを解説する。

1. Word DOMの不都合な真実:`StoryRanges`と`NextStoryRange`の構造

Word文書は、一見すると単一の連続したページに見えるが、内部アーキテクチャ的には複数の分離された論理領域(テキストストリーム)の集合体として構成されている。この論理領域をWord APIではストーリー(`WdStoryType`)と呼ぶ。

主要なストーリー種別(`WdStoryType`)

| 定数 (`WdStoryType`) | 値 | 概要 |
| :— | :— | :— |
| `wdMainTextStory` | 1 | 本文領域 |
| `wdPrimaryHeaderStory` | 6 | 既定のヘッダー |
| `wdPrimaryFooterStory` | 7 | 既定のフッター |
| `wdFirstPageHeaderStory` | 10 | 1ページ目のみ異なる場合のヘッダー |
| `wdFirstPageFooterStory` | 11 | 1ページ目のみ異なる場合のフッター |
| `wdEvenPagesHeaderStory` | 8 | 偶数ページのヘッダー |
| `wdEvenPagesFooterStory` | 9 | 偶数ページのフッター |
| `wdFootnotesStory` | 2 | 脚注 |
| `wdEndnotesStory` | 3 | 後注 |
| `wdCommentsStory` | 4 | コメント |
| `wdTextFrameStory` | 5 | テキストボックス/図形内のテキスト |

巨大な陥阱:`For Each rng In Document.StoryRanges` の罠

多くのWebリファレンスや生成AIが提示するサンプルコードには、以下のような壊滅的なバグを秘めたコードが含まれている。

‘ 【アンチパターン】これではテキストボックスや連結セクションの置換が漏れる
Dim rng As Range
For Each rng In ActiveDocument.StoryRanges
With rng.Find
.Text = “OldText”
.Replacement.Text = “NewText”
.Execute Replace:=wdReplaceAll
End With
Next rng

このコードがプロダクション環境で絶対に許されない理由は2つある。

1. リンクリスト構造の無視: `Document.StoryRanges` コレクションは、各ストーリータイプの先頭の要素(ヘッドノード)しか返さない。例えば、文書内に複数のリンクされていないテキストボックスが存在する場合、あるいは複数セクションに分かれた独立したヘッダーが存在する場合、`StoryRanges` を `For Each` で回すだけでは最初以外のノードを素通りする。
2. 未生成ストーリーの非活性: 文書構造上、まだアクセスされていないヘッダーやフッター(例: ユーザーが一度も開いていない偶数ページヘッダー)は、`StoryRanges` コレクション自体にロードされていないことがある。

全領域を網羅するには、`StoryRanges` で取得した各要素に対して、`NextStoryRange` プロパティを走査するポインタ追跡ループを実装しなければならない。

2. 極限のパフォーマンス最適化:Windows APIとレイアウトエンジンの抑制

WordはExcelと異なり、テキストの変更が発生するたびにリフロー(再レイアウト計算)と再描画(Repaint)を強力に実行する。特にヘッダー・フッター領域の `Range` オブジェクトにアクセスすると、Wordはビューモードを内部的に切り替え、画面描画スレッドを同期的に割り込ませる。

数万行の契約書や、数百個のテキストボックスを含む大型文書で一括置換を行う場合、VBA標準の `Application.ScreenUpdating = False` だけでは再描画イベントを完全に停止させることはできない

ここでシステムアーキテクトが採るべき手段は、Windows APIを直接呼出し、Wordのメインウィンドウ(`HWND`)に対するOSレベルの描画をシリアル化・停止させることである。

Windows APIによる描画停止(`WM_SETREDRAW`)

`user32.dll` の `SendMessage` を呼び出し、Wordウィンドウの描画パイプラインを物理的にフリーズさせる。

If VBA7 Then
Private Declare PtrSafe Function SendMessage Lib “user32” Alias “SendMessageA” ( _
ByVal hwnd As LongPtr, _
ByVal wMsg As Long, _
ByVal wParam As LongPtr, _
LParam As Any) As LongPtr
Else
Private Declare Function SendMessage Lib “user32” Alias “SendMessageA” ( _
ByVal hwnd As Long, _
ByVal wMsg As Long, _
ByVal wParam As Long, _
LParam As Any) As Long
End If

Private Const WM_SETREDRAW As Long = &HB

これとWordの動作抑制フラグ(`Undo` 情報のクリア、アニメーション停止)を組み合わせることで、処理速度は単にVBAを書いた場合の5倍〜20倍に跳ね上がる。

3. 完全掌握:全ストーリー対応 一括置換VBAモジュール

以下に、エンタープライズ運用に耐えうる堅牢な一括置換エンジンコードを示す。メモリリークを防ぐ明示的解放、エラー処理、全ストーリーおよび連結ノードの完全走査、OSレベルの描画制御を網羅している。

Option Explicit

‘ ==============================================================================
‘ Win32 API 宣言 (64bit / 32bit 互換)
‘ ==============================================================================
If VBA7 Then
Private Declare PtrSafe Function SendMessage Lib “user32” Alias “SendMessageA” ( _
ByVal hwnd As LongPtr, _
ByVal wMsg As Long, _
ByVal wParam As LongPtr, _
LParam As Any) As LongPtr
Else
Private Declare Function SendMessage Lib “user32” Alias “SendMessageA” ( _
ByVal hwnd As Long, _
ByVal wMsg As Long, _
ByVal wParam As Long, _
LParam As Any) As Long
End If

Private Const WM_SETREDRAW As Long = &HB

‘ ==============================================================================
‘ 処理名: ExecuteGlobalReplace
‘ 目的: 文書内の全ストーリー(本文、全ヘッダー/フッター、テキストボックス、注釈)を
‘ 漏れなく超高速に走査し、テキスト置換を実行する。
‘ ==============================================================================
Public Sub ExecuteGlobalReplace( _
ByRef targetDoc As Document, _
ByVal findText As String, _
ByVal replaceText As String, _
Optional ByVal matchCase As Boolean = False, _
Optional ByVal matchWholeWord As Boolean = False)

If targetDoc Is Nothing Then Exit Sub
If Len(findText) = 0 Then Exit Sub

Dim app As Word.Application
Set app = targetDoc.Application

‘ ————————————————————————–
‘ 1. アプリケーション状態のバックアップと高速化フラグの設定
‘ ————————————————————————–
Dim origScreenUpdating As Boolean
Dim origDisplayAlerts As WdAlertLevel
Dim origOptionsAnimation As Boolean

origScreenUpdating = app.ScreenUpdating
origDisplayAlerts = app.DisplayAlerts
origOptionsAnimation = app.Options.AnimateScreenMovements

app.ScreenUpdating = False
app.DisplayAlerts = wdAlertsNone
app.Options.AnimateScreenMovements = False

‘ OSレベルでのウィンドウ描画停止 (パフォーマンスの極限化)
SendMessage app.hwnd, WM_SETREDRAW, 0, ByVal 0&

On Error GoTo ErrorHandler

‘ ————————————————————————–
‘ 2. 全ストーリーおよび連結ノード(NextStoryRange)の完全走査
‘ ————————————————————————–
Dim currentStory As Range
Dim replaceCount As Long
replaceCount = 0

‘ Document.StoryRanges は各ストーリーの「先頭要素」のみを返す
For Each currentStory In targetDoc.StoryRanges

‘ ポインタ追跡ループ: リンクされた次のストーリーが存在する限り走査
Dim targetRange As Range
Set targetRange = currentStory

Do While Not (targetRange Is Nothing)

‘ 個別ストーリー内での置換処理呼び出し
replaceCount = replaceCount + PerformFindReplaceOnRange( _
targetRange, findText, replaceText, matchCase, matchWholeWord)

‘ 【重要】未評価の非活性ヘッダー/フッターを強制トリガーする副作用の補正
‘ テキストボックス等で NextStoryRange を正しく展開するためにアクセス
On Error Resume Next
Set targetRange = targetRange.NextStoryRange
On Error GoTo ErrorHandler
Loop

‘ 明示的メモリ解放 (COMオブジェクトの参照カウントを下げる)
Set currentStory = Nothing
Next currentStory

‘ ————————————————————————–
‘ 3. 後処理および状態復元
‘ ————————————————————————–
CleanUp:
‘ OSレベルでのウィンドウ描画再開
SendMessage app.hwnd, WM_SETREDRAW, 1, ByVal 0&

‘ 環境設定の復元
app.Options.AnimateScreenMovements = origOptionsAnimation
app.DisplayAlerts = origDisplayAlerts
app.ScreenUpdating = origScreenUpdating

‘ 画面の強制リフレッシュ
app.Refresh

‘ アンドゥバッファの開放(巨大ファイル操作時のメモリ高騰防止)
targetDoc.UndoClear

MsgBox “置換処理が完了しました。実行箇所数: ” & replaceCount & ” 件”, vbInformation, “処理完了”
Exit Sub

ErrorHandler:
‘ エラー発生時も確実に描画フラグを全復元して脱出する
Resume CleanUp
End Sub

‘ ==============================================================================
‘ 処理名: PerformFindReplaceOnRange
‘ 目的: 指定された単一の Range オブジェクトに対して Find/Replace を実行する
‘ 返り値: 置換成功回数
‘ ==============================================================================
Private Function PerformFindReplaceOnRange( _
ByVal targetRng As Range, _
ByVal findText As String, _
ByVal replaceText As String, _
ByVal matchCase As Boolean, _
ByVal matchWholeWord As Boolean) As Long

Dim localCount As Long
localCount = 0

With targetRng.Find
.ClearFormatting
.Replacement.ClearFormatting

.Text = findText
.Replacement.Text = replaceText

.Forward = True
.Wrap = wdFindStop ‘ ストーリー境界を超えてループするのを防ぐ(必須)
.Format = False
.MatchCase = matchCase
.MatchWholeWord = matchWholeWord
.MatchWildcards = False
.MatchSoundsLike = False
.MatchAllWordForms = False

‘ Executeを実行するたびにRangeオブジェクトの選択領域が置換後の文字列にシフトする
Do While .Execute(Replace:=wdReplaceOne)
If targetRng.Find.Found Then
localCount = localCount + 1
‘ 無限ループ防止: 置換後テキスト内に置換対象テキストが含まれる場合のガード
targetRng.Collapse wdCollapseEnd
Else
Exit Do
End If
Loop
End With

PerformFindReplaceOnRange = localCount
End Function

4. アーキテクチャの真髄:解説と注意すべきエッジケース

上記コードには、シニアエンジニアが押さえておくべき高度なアーキテクチャ設計が含まれている。

① `.Wrap = wdFindStop` の絶対性

`Find` オブジェクトの `.Wrap` プロパティに `wdFindContinue` や `wdFindAsk` を設定してはならない。これらを設定すると、ストーリーの終端に達した際、Wordはそのストーリーの先頭に戻って走査を繰り返すため、無限ループに陥る。各ストーリーを厳格にカプセル化して置換を行うには、必ず `wdFindStop` を指定し、制御権をVBAの `Do While` ループ側に保持しなければならない。

② アンドゥバッファ(`UndoClear`)とメモリ領域のクリーンアップ

数万箇所に及ぶ大量置換を行うと、Wordの「元に戻す(Undo)」バッファが巨大なRAMを消費し、`Out of Memory`(エラー番号 7)を引き起こす。処理完了時に `targetDoc.UndoClear` を呼び出し、内部のスタック情報を確定解放させるのがレガシー環境保守の鉄則である。

③ C# / VB.NET (COM Interop) 連携時の警告

本ロジックを C# などの外部プロセス(.NET Interop)から呼び出す場合、さらに深刻なRCW (Runtime Callable Wrapper) のメモリリーク問題が発生する。

.NETで `doc.StoryRanges` や `NextStoryRange` を走査すると、ループの度に透明なCOMプロキシオブジェクトがヒープ上に生成され、.NETのガベージコレクタ(GC)はWord COMの参照カウントを正しくタイミングよく解放できない。

.NET環境へ移植する場合は、以下のように `Marshal.ReleaseComObject()` を毎ループ確定明示実行 する決定論的破棄パターンを適用しなければならない。

// C# による COM Interop 移植例の一部
Word.Range storyRange = doc.StoryRanges[Word.WdStoryType.wdMainTextStory];
while (storyRange != null)
{
// 置換処理を実行…

Word.Range nextStoryRange = storyRange.NextStoryRange;

// 不要になった COM オブジェクトを即座に物理解放
Marshal.ReleaseComObject(storyRange);

storyRange = nextStoryRange;
}

5. 結論

Word VBAにおけるテキスト処理は、Excelのような直感的なアドレス指定が通用しない。
Wordの本質は「非同期に描画される複数のテキストストリーム(ストーリー)の集合体」である。

1. `StoryRanges` の単一ループに頼らず、`NextStoryRange` を最後まで辿るリンクリスト走査を組むこと。
2. `WM_SETREDRAW` APIを用いて、Wordの同期描画パイプラインをOSレベルで遮断すること。
3. `wdFindStop` と `Collapse` による非ループ保証と、COM参照の明示的解放を行うこと。

この3原則を遵守したアーキテクチャを構築して初めて、大規模エンタープライズシステムにおいて「漏れがなく、落ちない」真のドキュメントオートメーションが完成する。

タイトルとURLをコピーしました