WordのVSTOアドインで検索置換やスタイル適用を「1回の取り消し(Undo)」にまとめようとして、UndoRecord.StartCustomRecordを使ったら、箇条書き・番号付きリストが含まれる文書でUndo履歴が壊れる――この現象は珍しくありません。本記事では再現条件から内部メカニズムの推測、実運用で使える回避策、堅牢なコード例、テスト観点までを一気通貫で解説します。
問題の全体像と結論
Wordでは、箇条書き・番号付きリスト(以下「リスト」)の書式変更が段落単位・レベル単位で非常に細かい内部Undoに分割されます。ここに開発者がUndoRecord.StartCustomRecord()で「カスタムUndo(トランザクション)」を重ねると、Word側のリスト用Undoと競合し、以下の異常を誘発します。
- 「すべて置換」や一括スタイル適用が細切れのUndoに分割される
- それ以前のUndo履歴が消える・巻き戻る
- 開始したはずのカスタムUndoレコード自体が履歴に表示されない
この挙動はアドイン側のコーディングミスというより、WordのUndo APIとリスト内部Undoの相互作用による仕様上の制限/バグ相当と考えるのが妥当です。
現実解としては、「リストを検知したらカスタムUndoを使わない」「操作を小分けにして複数レコードにする」「一時的にリストを外してから処理して復元」といった回避戦略を、対象文書とユーザーの期待に合わせて選択・併用します。
再現条件と典型シナリオ
以下のいずれか、あるいは複合で高確率に再現します。
- 選択範囲(または検索対象範囲)にリスト段落が含まれている
- 「すべて置換(
wdReplaceAll)」で広範囲にスタイル/書式も変更する - 複数ストーリー(本文・ヘッダー・脚注など)を横断する置換を行う
- 変更履歴(Track Revisions)がオン、またはコンテンツコントロール・フィールドを含む
症状は次の通りです。
- 「すべて置換」をUndoすると、文書の離れた場所のリスト番号だけが1回ずつ取り消されるなど、操作が意味的に分断される。
- 操作前に蓄積されていたUndo履歴が消失または巻き戻り、過去操作へ戻れないことがある。
StartCustomRecord()で与えた名称のレコードが履歴に現れない(実質ノーオペに見える)。
内部メカニズムの推測(なぜ壊れるのか)
Wordのリストは、見た目の「番号」「箇条書き記号」だけでなく、段落のレベル、リストテンプレート、連番の継続・再開など複数の状態を保持します。これらは編集単位ごとに独立した内部Undoへ分割されるため、アドインがまとめて1トランザクション化しようとしても、Word内部の「細かい段落レベルUndo」が先回りしてスタックへ積まれてしまいます。
結果として、カスタムUndo(開発者)とリストUndo(アプリ内部)の整合性が崩れ、履歴表示や取り消し順序の破綻を招きます。
回避策の全体比較
| 方法 | 概要 | メリット | デメリット | 適用判断の目安 |
|---|---|---|---|---|
| 1. リスト検知でカスタムUndoを回避 | Range.ListFormat.ListType等で範囲内にリストがあればStartCustomRecordを使わない | 実装が簡単。安全性が高い | リストを含む操作は複数Undoに分割される | ユーザーが「完全1ステップ」に強くこだわらない場合 |
| 2. 操作を小分けに複数レコード化 | 検索置換、スタイル、微調整をそれぞれ別のStart/Endで記録 | 衝突を低減。履歴ラベルも分かりやすい | 完全な1ステップ化は困難 | 履歴の可読性を重視し、破綻を避けたい場合 |
| 3. 一時的にリスト書式を解除 | ListFormat.RemoveNumbers()等で外してから処理し、復元 | 多くのケースで1ステップにできる | 復元が難しい。大量段落で性能劣化 | 厳密な1ステップ化が必要で、リストの体裁が均一な文書 |
| 4. 制限として仕様化 | ヘルプ等に「リスト段落では単一Undoにならない場合あり」と明記 | 実装コストゼロ | 体験に妥協が必要 | 業務要件で十分受容可能な場合 |
安全な実装パターン:リスト対応トランザクションラッパー
まずは「使えれば使う、危なければ使わない」という適応的ラッパーを用意します。
using Word = Microsoft.Office.Interop.Word;
using Office = Microsoft.Office.Core;
public static class UndoHelper
{
public static bool RangeLikelyContainsList(Word.Range range)
{
// 速い近似判定:開始・終了段落をチェック
var start = range.Duplicate;
start.Collapse(Word.WdCollapseDirection.wdCollapseStart);
if (start.ListFormat.ListType != Word.WdListType.wdListNoNumbering) return true;
```
var end = range.Duplicate;
end.Collapse(Word.WdCollapseDirection.wdCollapseEnd);
if (end.ListFormat.ListType != Word.WdListType.wdListNoNumbering) return true;
// 長大な範囲はサンプリング(50段落ごと等)
int count = range.Paragraphs.Count;
int step = count > 500 ? 50 : 10;
for (int i = 1; i <= count; i += step)
{
var p = range.Paragraphs[i];
if (p.Range.ListFormat.ListType != Word.WdListType.wdListNoNumbering)
return true;
}
return false;
}
public static IDisposable TryStartCustomRecord(Office.UndoRecord undo, string caption, bool enable)
{
if (!enable || undo == null) return new Noop();
try
{
undo.StartCustomRecord(caption);
return new Ender(undo);
}
catch
{
return new Noop(); // 失敗したら静かにフォールバック
}
}
private sealed class Noop : IDisposable { public void Dispose() { } }
private sealed class Ender : IDisposable
{
private readonly Office.UndoRecord _undo;
public Ender(Office.UndoRecord undo) { _undo = undo; }
public void Dispose()
{
try { if (_undo.IsRecordingCustomRecord) _undo.EndCustomRecord(); }
catch { /* 破損時でも落とさない */ }
}
}
```
}
使い方(usingを活用して確実にEndさせる):
var app = Globals.ThisAddIn.Application;
var undo = app.UndoRecord;
var hasList = UndoHelper.RangeLikelyContainsList(targetRange);
using (UndoHelper.TryStartCustomRecord(undo, "一括変換", enable: !hasList))
{
// 検索置換・スタイル適用など
}
ポイントは、「カスタムUndoを開始できない/危険と判定したら素直に諦める」ことです。これで「履歴が壊れて過去に戻れない」という最悪の事態を避けられます。
操作を小分けにする(複数レコード化)
検索置換とスタイル適用を分割し、それぞれに短いカスタムUndoをつけると、リスト内部Undoとの衝突が低減します。
void ReplaceAll(Word.Range scope, string findText, string replaceText)
{
var f = scope.Find;
f.ClearFormatting();
f.Replacement.ClearFormatting();
f.Text = findText;
f.Replacement.Text = replaceText;
f.Forward = true;
f.Wrap = Word.WdFindWrap.wdFindStop;
```
object replaceAll = Word.WdReplace.wdReplaceAll;
f.Execute(Replace: ref replaceAll);
```
}
void ApplyStyleToMatches(Word.Range scope, string pattern, object style)
{
var r = scope.Duplicate;
var f = r.Find;
f.ClearFormatting();
f.Replacement.ClearFormatting();
f.Text = pattern;
f.Replacement.Style = style; // 書式置換でスタイル適用
f.Forward = true;
f.Wrap = Word.WdFindWrap.wdFindStop;
f.Format = true;
```
object replaceAll = Word.WdReplace.wdReplaceAll;
f.Execute(Replace: ref replaceAll);
```
}
void ExecuteInChunks(Word.Range scope)
{
var app = Globals.ThisAddIn.Application;
var undo = app.UndoRecord;
```
// チャンク1:置換
using (UndoHelper.TryStartCustomRecord(undo, "検索と置換", enable: !UndoHelper.RangeLikelyContainsList(scope)))
{
ReplaceAll(scope, "foo", "bar");
}
// チャンク2:スタイル
using (UndoHelper.TryStartCustomRecord(undo, "スタイル適用", enable: !UndoHelper.RangeLikelyContainsList(scope)))
{
ApplyStyleToMatches(scope, "bar", Word.WdBuiltinStyle.wdStyleStrong);
}
```
}
履歴は2行になりますが、いずれも意味単位でまとまるためユーザーは理解しやすくなります。
一時的にリストを書式解除してから復元する
どうしても1ステップにまとめたい場合の荒技です。対象範囲のリストを一旦外し、編集後に復元します。
class ListSnapshot
{
public int ParagraphIndex; // 範囲先頭からの相対番号(簡易)
public Word.WdListType ListType;
public int Level;
}
IList DetachLists(Word.Range scope)
{
var listInfo = new List();
int i = 0;
foreach (Word.Paragraph p in scope.Paragraphs)
{
i++;
var lf = p.Range.ListFormat;
if (lf.ListType != Word.WdListType.wdListNoNumbering)
{
listInfo.Add(new ListSnapshot
{
ParagraphIndex = i,
ListType = lf.ListType,
Level = lf.ListLevelNumber
});
// 一時的に外す(番号をテキスト化せず、書式だけ解除)
lf.RemoveNumbers();
}
}
return listInfo;
}
void RestoreLists(Word.Range scope, IList snaps)
{
// 注意:カスタムテンプレート・連番継続までは完全復元できない場合あり
for (int i = 0; i < snaps.Count; i++)
{
var s = snaps[i];
var p = scope.Paragraphs[s.ParagraphIndex];
if (s.ListType == Word.WdListType.wdListBullet)
p.Range.ListFormat.ApplyBulletDefault();
else
p.Range.ListFormat.ApplyNumberDefault();
```
if (s.Level > 1)
p.Range.ListFormat.ApplyListTemplateWithLevel(
ListTemplate: p.Range.ListFormat.ListTemplate,
ContinuePreviousList: true,
ApplyTo: Word.WdListApplyTo.wdListApplyToWholeList,
DefaultListBehavior: Word.WdDefaultListBehavior.wdWord10ListBehavior,
ApplyLevel: s.Level);
}
```
}
重要な注意点:
- カスタムの番号書式(例:
1-1-1.など)や、段落間の連番継続・再開は完全には戻らないことがあります。 - 数百〜数千段落の文書では性能劣化が顕著。スコープを絞るか、許容できる規模に限定してください。
堅牢化のための運用Tips
IsRecordingCustomRecordの監視:例外でEndCustomRecordが漏れやすいので、finallyで必ず終了させる。- 対象DocumentのUndoRecordを使う:複数文書を開くアドインでは、現在のアクティブ文書とは別の
Documentを触ることがあるため、ActiveDocument依存を避ける。 - イベントの無効化:
Application.ScreenUpdating、EventsEnabledの一時停止で揺らぎを軽減(ただし最小限に)。 - 変更履歴ON時はさらに壊れやすい:重要業務では、処理中のみ変更履歴を一時OFFにするオプションを検討(ユーザー通知と同意を必須)。
- 複数ストーリーの分割処理:本文、脚注、ヘッダー等を別々に処理し、各ストーリーごとに小さなカスタムUndoで囲う。
テスト観点(チェックリスト)
| 観点 | テスト内容 | 期待結果 |
|---|---|---|
| 単純文書 | リストなし・プレーン段落のみで置換+スタイル | 1レコードでUndo/Redoが安定 |
| 混在文書 | 通常段落と箇条書き・段落見出しが混在 | ラッパーがリストを検知し、レコード分割または回避 |
| 巨大文書 | 1,000段落超、複数セクション・ヘッダー/脚注あり | 性能劣化が許容範囲。履歴破損なし |
| 変更履歴 | Track Revisions ONで同操作 | 必要に応じてカスタムUndoを諦め、履歴の健全性優先 |
| フィールド | 目次・相互参照・数式等のフィールドを含む | フィールド更新のタイミングでUndo破綻がない |
| 再実行性 | 連続実行・元に戻す/やり直すの往復 | 履歴順序が崩れない |
よくある誤解とFAQ
Q:StartCustomRecordで確実に1回のUndoにできますよね?
A:いいえ。リストや変更履歴、複数ストーリーを伴うとWord内部Undoが細分化され、確実性は下がります。上記のラッパーやレコード分割で壊れないことを最優先にします。
Q:検索置換だけなら安全ですか?
A:テキストのみの置換は比較的安全ですが、「スタイル等の書式置換」を同時に行うと内部Undoが増えます。テキスト置換と書式適用を分けるのが無難です。
Q:ConvertNumbersToTextで番号をテキスト化して編集するのは?
A:編集は安定しますが、番号が実文字化されるため元に戻せません。復元性が必要な場合は不適です。
Q:COM例外でEndCustomRecordできなくなりました。
A:finallyでIsRecordingCustomRecordを必ず確認し、例外でも終了する構造にしましょう。どうしても矛盾した場合は、そこで以後のカスタムUndoを停止するのが安全です。
「壊れない」ことを最優先にした実装例(完成版)
using Word = Microsoft.Office.Interop.Word;
using Office = Microsoft.Office.Core;
public class BulkFormatter
{
private readonly Word.Application _app;
public BulkFormatter(Word.Application app) => _app = app;
```
public void ReplaceAndStyle(Word.Range scope, string findText, string replaceText, object style)
{
// 表示負荷を軽減(必ず復帰)
bool screenUpdating = _app.ScreenUpdating;
bool eventsEnabled = _app.EventsEnabled;
_app.ScreenUpdating = false;
_app.EventsEnabled = false;
try
{
var undo = _app.UndoRecord;
bool hasList = UndoHelper.RangeLikelyContainsList(scope);
// 1チャンク目:テキスト置換
using (UndoHelper.TryStartCustomRecord(undo, "検索と置換", enable: !hasList))
{
ReplaceAll(scope, findText, replaceText);
}
// 2チャンク目:スタイル適用(書式置換)
using (UndoHelper.TryStartCustomRecord(undo, "スタイル適用", enable: !hasList))
{
ApplyStyleToMatches(scope, replaceText, style);
}
}
finally
{
_app.EventsEnabled = eventsEnabled;
_app.ScreenUpdating = screenUpdating;
}
}
private static void ReplaceAll(Word.Range scope, string findText, string replaceText)
{
var f = scope.Find;
f.ClearFormatting();
f.Replacement.ClearFormatting();
f.Text = findText;
f.Replacement.Text = replaceText;
f.Forward = true;
f.Wrap = Word.WdFindWrap.wdFindStop;
object replaceAll = Word.WdReplace.wdReplaceAll;
f.Execute(Replace: ref replaceAll);
}
private static void ApplyStyleToMatches(Word.Range scope, string pattern, object style)
{
var r = scope.Duplicate;
var f = r.Find;
f.ClearFormatting();
f.Replacement.ClearFormatting();
f.Text = pattern;
f.Replacement.Style = style;
f.Format = true;
f.Forward = true;
f.Wrap = Word.WdFindWrap.wdFindStop;
object replaceAll = Word.WdReplace.wdReplaceAll;
f.Execute(Replace: ref replaceAll);
}
```
}
この実装は、リストを含むときはカスタムUndoを自動で諦めるため、Undo破綻の確率を大きく下げます。要件として「絶対に1ステップにまとめたい」場合のみ、前述の「一時解除→復元」方式を限定的に採用してください。
診断とロギング
- 開始時・終了時に
IsRecordingCustomRecordの値、対象ストーリー、段落数、リスト検知数をログ。 - Undo破綻疑いが出たら、以後のセッションでカスタムUndoを自動停止するフェイルセーフを用意。
- ユーザー報告用に「処理概要」「影響範囲」「Undo履歴の状態」をダイアログで示せるようにする。
実務での意思決定ガイド(テキスト版フロー)
文書にリストが含まれる? → Yes なら「カスタムUndoは原則オフ」。No なら次へ。
変更履歴ON? → Yes なら「操作をチャンク化」「履歴健全性を優先」。No なら次へ。
フィールド・コンテンツコントロール多数? → Yes なら「ストーリー分割」「チャンク化」。No なら「1トランザクションを試行」。
いずれかで破綻の兆候(履歴消失/分割)? → 即座にカスタムUndoを停止しフォールバック。
既知の制約と将来に備えた設計
- APIの限界:カスタムUndoと内部Undoは最終的にWord側の裁量。完全な制御はできません。
- ユーザー告知:ヘルプやリリースノートに「リスト段落では単一Undoにならない場合がある」旨を明記し、期待値を合わせる。
- 機能トグル:アドイン設定で「Undoをできるだけまとめる(ベータ)」のようなスイッチを用意し、トラブル時はオフに切替可能に。
- テレメトリ:Undo破綻の検知(カスタムレコード不出現・履歴巻き戻り)を匿名集計し、状況に応じて既定値を変更。
まとめ
UndoRecord.StartCustomRecordは、Word VSTO開発で「意味のある1ステップUndo」を実現する強力な機能ですが、リストの内部Undoと競合すると履歴破綻のリスクがあります。
リスト検知で回避/操作のチャンク化/一時解除と復元/仕様として明示の4路線を使い分け、まずは「壊れないこと」を優先してください。堅牢なラッパーとテスト観点を整えれば、ユーザー体験と安定性のバランスを高い次元で両立できます。

コメント