Word VSTO:UndoRecord.StartCustomRecordで箇条書き・番号付きリストのUndoが壊れる原因と回避策【完全ガイド】

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)がオン、またはコンテンツコントロール・フィールドを含む

症状は次の通りです。

  1. 「すべて置換」をUndoすると、文書の離れた場所のリスト番号だけが1回ずつ取り消されるなど、操作が意味的に分断される。
  2. 操作前に蓄積されていたUndo履歴が消失または巻き戻り、過去操作へ戻れないことがある。
  3. 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 &gt; 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路線を使い分け、まずは「壊れないこと」を優先してください。堅牢なラッパーとテスト観点を整えれば、ユーザー体験と安定性のバランスを高い次元で両立できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次