C#でWord文書内を文字列検索して連番に置換するプログラムを書いてみた


C#からMS Officeの操作をするとき、Excelに比べてWordの情報が少ないので、書いてみた。
(検索すれば、開く・保存するくらいはすぐに見つかるが、それ以上のことがあまり出てこない。)

# やれること

C#でWord文書上でキーワードを検索して、連番に置き換える。
Windows7以降であればインストール不要で使えます。(Wordは要るけど。)


# 処理前のWord文書

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/7bbb1151-d837-7e64-a294-83f3dd8e1190.png)

# 処理後のWord文書

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/5e1c35c0-79ce-1c05-c841-f7fe6eaeffea.png)

# 画面

ラベル振るのさぼりました。
使い方はソースをみてください。。。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/438183/ea017f98-1790-3721-7de5-08614e42fde8.png)

# 注意事項

- COMオブジェクトの解放はGCまかせにしてます。
- 途中でWord文書を閉じたりすると、例外で落ちます。
- 開く対象のWord文書は、あらかじめ閉じておいてください。
- C#から操作した場合に、Word文書内の変更履歴に残るのか未確認
- 検索対象が素のテキストだけか未確認。（相互参照とか目次とかヘッダーフッターとか引っかかってしまうかも）
- <b>何か起きても責任はとれませんので、自己責任でご利用ください。</b>

# ソースコード

```csharp:WordInteropTest.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows.Forms;
using System.Runtime.CompilerServices;

using Word = Microsoft.Office.Interop.Word;


// 再度実行するには、本アプリケーションで起動したWordを閉じ、本アプリケーションを再起動してください。

class WordTest : Form
{
    const int ForwardLentgh = 50;

    Word.Application app;
    Word.Document doc;

    Button btnLoad;
    TextBox txtFindPrefix;           // 検索対象の接頭語
    TextBox txtFindRegex;            // 検索対象の正規表現(接頭語を含める)
    TextBox txtReplacerPrefix;       // 置換後のIDの接頭語
    NumericUpDown nudReplacerDigits; // 置換後のIDの数値部の桁数
    NumericUpDown nudReplacerId;     // 置換後のIDの数値部
    Button btnResetPos;
    Button btnFindReplace;
    
    WordTest()
    {
        btnLoad = new Button();
        btnLoad.Text = "Open Word File.";
        btnLoad.Size = new Size(150,25);
        btnLoad.Click += (sender,e)=>{LoadWordFile();};
        Controls.Add(btnLoad);

        txtFindPrefix = new TextBox();
        txtFindPrefix.Location = new Point(0,50);
        txtFindPrefix.Text = "BeforeID"; // Wordが認識する特殊文字あり注意(^t や ^p など))
        Controls.Add(txtFindPrefix);

        txtFindRegex = new TextBox();
        txtFindRegex.Location = new Point(0,75);
        txtFindRegex.Width = 150;
        txtFindRegex.Text = @"BeforeID[0-9]{5}\b"; //  \b は(C#の正規表現では)単語境界を示す。
        Controls.Add(txtFindRegex);

        nudReplacerDigits = new NumericUpDown();
        nudReplacerDigits.Location = new Point(150,75);
        nudReplacerDigits.Width = 45;
        nudReplacerDigits.Maximum = 7;
        nudReplacerDigits.Value = 5;
        nudReplacerDigits.Minimum = 1;
        Controls.Add(nudReplacerDigits);

        txtReplacerPrefix = new TextBox();
        txtReplacerPrefix.Location = new Point(0,120);
        txtReplacerPrefix.Text = "AfterID";
        Controls.Add(txtReplacerPrefix);

        nudReplacerId = new NumericUpDown();
        nudReplacerId.Location = new Point(100,120);
        nudReplacerId.Width = 80;
        nudReplacerId.Maximum = 9999999;
        nudReplacerId.Value = 1;
        nudReplacerId.Minimum = 0;
        Controls.Add(nudReplacerId);

        
        btnResetPos = new Button();
        btnResetPos.Location = new Point(0,200);
        btnResetPos.Text = "検索位置を先頭に戻す";
        btnResetPos.Size = new Size(150,25);
        btnResetPos.Click += (sender,e)=>{ResetFindPos();};
        btnResetPos.Enabled = false;
        Controls.Add(btnResetPos);

        
        btnFindReplace = new Button();
        btnFindReplace.Location = new Point(0,250);
        btnFindReplace.Text = "Find and Replace";
        btnFindReplace.Size = new Size(150,25);
        btnFindReplace.Click += (sender,e)=>{FindAndReplace();};
        btnFindReplace.Enabled = false;
        Controls.Add(btnFindReplace);

        ClientSize = new Size(350,400);
        Text = "Word ID replacer";
    }


    void LoadWordFile()
    {
        object oMis = Missing.Value; // System.Reflection.Missing.Value
        object oTru = true;
        object oFal = false;

        
        object oPth = GetOpenFileNameFromDialog();
        if ( oPth == null ) {
            return;
        }

        btnLoad.Enabled = false;
        btnResetPos.Enabled = true;
        btnFindReplace.Enabled = true;

        app = new Word.Application();
        
        app.Application.Visible = true; // true:Wordを表示させる  false:表示させない
        // app.Application.DisplayAlerts = Word.WdAlertLevel.wdAlertsNone; // ← 表示させない場合はこれも併せて指定するとよいらしい

        // https://docs.microsoft.com/ja-jp/dotnet/api/microsoft.office.interop.word.documents.open?view=word-pia
        // ※Revert引数は検討必要。
        doc = app.Documents.Open(
            ref oPth,// FileName
                     // ※相対パスで指定すると、ドキュメントフォルダを探しにいくようなので注意。
                     //   (Fileクラスと同じような振る舞いを期待しているとはまります。)
            ref oMis,// ConfirmConversions:
                     //   True to display the Convert File dialog box if the file isn't in Microsoft Word format.
            ref oFal,// ReadOnly: True to open the document as read-only.
            ref oMis,// AddToRecentFiles:
                     //   True to add the file name to the list of recently used files at the bottom of the File menu.
            ref oMis,// PasswordDocument:  The password for opening the document.
            ref oMis,// PasswordTemplate:  The password for opening the template.
            ref oFal,// Revert:  Controls what happens if FileName is the name of an open document. 
                     //   True to discard any unsaved changes to the open document and reopen the file.
                     //   False to activate the open document.
            ref oMis,// WritePasswordDocument: The password for saving changes to the document.
            ref oMis,// WritePasswordTemplate: The password for saving changes to the template.
            ref oMis,// Format: The file converter to be used to open the document. Can be a WdOpenFormat constant.
            ref oMis,// Encoding: Can be any valid MsoEncoding constant. The default value is the system code page.
            ref oMis,// Visible:
            ref oMis,// OpenAndRepair
            ref oMis,// DocumentDirection: wdLeftToRight or wdRightToLeft
            ref oMis,// NoEncodingDialog:
                     //   True to skip displaying the Encoding dialog box that Word displays if the text encoding cannot be recognized.
            ref oMis // XMLTransform
        );
    }

    
    [MethodImpl(MethodImplOptions.NoInlining)]
    void FindAndReplace()
    {
        object oMis = Missing.Value; // System.Reflection.Missing.Value
        object oTru = true;
        object oFal = false;
        object oFindStop = Word.WdFindWrap.wdFindStop;
        object oNoReplace = Word.WdReplace.wdReplaceNone;

        Regex r;
        try {
            r = new Regex("^" + txtFindRegex.Text); 
        }
        catch(ArgumentException e) {
            MessageBox.Show(e.ToString());
            return;
        }

        object oFindText = txtFindPrefix.Text;
        string prefixNew = txtReplacerPrefix.Text;
        int idNew = (int)nudReplacerId.Value;
        int digits = (int)nudReplacerDigits.Value;


        
        if ( app.Selection.Document != doc ) {
            // テキスト選択
            doc.Select();
            app.Selection.Start = 0;
            app.Selection.End = 0;
        }
        else {
            app.Selection.End = app.Selection.Start;
        }

        // https://docs.microsoft.com/ja-jp/dotnet/api/microsoft.office.interop.word.find.execute?view=word-pia

        for (;;) {
            // 固定部分を探す
            bool foundPrefix = app.Selection.Find.Execute (
                ref oFindText,// FindText: 特殊文字あり注意。
                            //   You can search for special characters by specifying appropriate character codes.
                            //   For example, "^p" corresponds to a paragraph mark and "^t" corresponds to a tab character. 
                ref oTru,// MatchCase: True to specify that the find text be case-sensitive.
                ref oFal,// MatchWholeWord: 単語単位での検索
                ref oFal,// MatchWildcards
                ref oFal,// MatchSoundsLike: あいまい検索
                ref oFal,// MatchAllWordForms:
                        //   True to have the find operation locate all forms of the find text (for example, "sit" locates "sitting" and "sat").
                ref oTru,// Forward: Trueで前方検索
                ref oFindStop,// Wrap: 検索しきった後に、最初から検索しなおすかどうかを指定する
                ref oMis,// Format: よくわからない
                ref oMis,// ReplaceWith
                ref oNoReplace,// Replace: ここではReplaceしない指定とする (oMisでもよいのかも)
                ref oMis,// MatchKashida: アラビア語関連の機能らしい。
                ref oMis,// MatchDiacritics: 右から左に読む言語関連の機能らしい。
                ref oMis,// MatchAlefHamza: アラビア語関連の機能らしい。
                ref oMis // MatchControl: 右から左に読む言語関連の機能らしい。
            );

            // 見つからなかったら検索終了
            if ( !foundPrefix ) {
                MessageBox.Show("Not found.");
                app.Selection.Start = 0;
                app.Selection.End = 0;
                break;
            }

            // 選択範囲を拡張する。
            app.Selection.End += ForwardLentgh;

            // 指定された正規表現にマッチするかチェックする
            string s = app.Selection.Text;
            Match m = r.Match(s);

            if ( m.Success ) {
                string matchPart = m.Groups[0].Value;
                int startPos = app.Selection.Start;
                string replacerStr = prefixNew + idNew.ToString("D"+digits.ToString());

                app.Selection.End = startPos + matchPart.Length; // 選択範囲を変更。(先頭は一致している前提。)
                app.Selection.Text = replacerStr;

                nudReplacerId.Value++;

                // 次に検索ボタンを押したときのために、検索位置を進める
                app.Selection.Start = startPos + replacerStr.Length;
                app.Selection.End = app.Selection.Start;

                break;
            }
            else { // 正規表現にはマッチしなかった。
                // 次を検索させる。
                Console.WriteLine("Info: Regex mismatch. " + s);
                app.Selection.Start = app.Selection.End;
            }
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    void ResetFindPos()
    {
        app.Selection.Start = 0;
        app.Selection.End = 0;
    }

    string GetOpenFileNameFromDialog()
    {
        var ofd = new OpenFileDialog();
        ofd.Filter = "MS Word File(*.doc;*.docx)|*.doc;*.docx|All files|*";
        ofd.Title = "Select file";
        ofd.RestoreDirectory = true;

        if ( ofd.ShowDialog() == DialogResult.OK ) {
            return ofd.FileName;
        }
        else {
            return null;
        }
    }


    // RunApp()に処理を分離して属性指定している目的:
    //   JITによるinline展開を禁止することで、
    //   Mainメソッド内のGC.Collect()までに余計な(Wordの)COM参照オブジェクトが残らないことを期待する。
    //   ただし、期待通りに動作しているかは確認していない。(そもそも要るのか不明。。)
    [MethodImpl(MethodImplOptions.NoInlining)]
    static void RunApp()
    {
        Application.Run(new WordTest());
    }

    [STAThread]
    static void Main()
    {
        RunApp();
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }
}
```

# コンパイル方法

dllの場所は環境依存と思います。
`C:\Windows\assembly\GAC_MSIL\`以下を`dir /s Microsoft.Office.Interop.Word.dll`で検索すればいくつか出てくるはずなので、最新っぽいのを使えばよいはず。

```
csc /r:C:\Windows\assembly\GAC_MSIL\Microsoft.Office.Interop.Word\15.0.0.0__71e9bce111e9429c\Microsoft.Office.Interop.Word.dll ^
 WordInteropTest.cs

```

cscのパスの通し方はググってね。