セル内の「取り消し線で消された文字列」を削除してみた

2021年2月24日

どもです。

今回は、C#/OpenXmlの組み合わせで、エクセルのセル内の文字列から
「取り消し線で消されていない文字列のみを取得する方法」
について書きます。

1.何があったか

最近、所々でエクセルで作成したドキュメントの変更履歴/修正履歴を、ドキュメント内に残している場面に出くわしました。
現物は(もちろんですが)公開できませんが、イメージはこんなカンジ。
cs_openxml_remove_strikethrough_001
※本当はもう少し書かれている内容は多いのですが、今回のエントリに不要な情報は削除しています。
このような取り消し線は、セル内に1つとか2つ、ドキュメント全体を通しても3箇所とか4箇所に登場しないのであれば、そんなに問題ありません。
しかし、当該のエクセルでは、このような記載が複数個所、それこそ「セル内の文字列の半分が取り消し線で消されている」とか、「取り消し線で10文字消した後、1文字消されていない文字があって、その後再度20文字(くらい)取り消し線で消されている」なんて箇所もあったりしました。
そのようなエクセル、読み難くてしょうがない。
(現に、この1文字を読み損ねてエライ目に遭いました。)
そこで、セル内の「取り消し線で消された文字列」を取り除いた文字列を取得できるようにしてみた、というわけです。

2.開発環境

今回の調査で使用した環境を、以下に示します。

項目 内容
OS Windows10 Pro(1909)
CPU i7-8700
メモリ 16GB
IDE VisualStudioCommnuity 2019
version 16.5.1
.NET Framework 4.7
OpenXml 2.11.3
Excel Office 2013

3.データ解析

3.1.エクセルの中の見える化

拡張子が「.xlsx」のエクセルファイルは、その実体はXMLファイルをzip形式で圧縮したファイルです。
そのため、Windowsのエクスプローラ上で拡張子をzipに書き換えて解凍することで、その中身を確認することができます。
例えば、先に見せたエクセルを展開した場合、展開したフォルダ内の「sharedStrings.xml」の中に、各セルの情報を見つけることができます。
cs_openxml_remove_strikethrough_002

3.2.エクセルの中を見てみるか

上記のsharedStrings.xmlをテキストエディタで開いてみると、中身は下記のようになっています。
(全部表示すると、長くなるので、必要な箇所のみの抜粋です。)

<si>
    <r>
        <rPr>
            <strike/>
            <sz val="9"/>
            <color theme="1"/>
            <rFont val="Meiryo UI"/>
            <family val="3"/>
            <charset val="128"/>
        </rPr>
        <t>CountrySide</t>
    </r>
    <r>
        <rPr>
            <sz val="9"/>
            <color theme="1"/>
            <rFont val="Meiryo UI"/>
            <family val="3"/>
            <charset val="128"/>
        </rPr>
        <t>MetropolitanLivingEngineer</t>
    </r>
    <phoneticPr fontId="2"/>
</si>

これは、最初の図でB2セルの情報になります。
この情報から分かるように、取り消し線が設定された文字列では、rPr要素の子要素にstrikeという要素が存在します。
この要素の有無を判断することで、セル内の文字列に取り消し線が設定されているか否かが判断できそうです。

4.実装

ここまでで、rPr要素がstrike要素を持つか否かで取り消し線があるか否かを判断できそうだと分かりました。
これを元に実装を行います。

4.1.シートのセル全体の読み出し

エクセルファイルをオープンして、ファイル/シートのセル情報を取得するまでの処理は、下記になります。

class RemoveStrikeThroughExcel
{
    static void Main(string[] args)
    {
        var excelFileName = @"./../image.xlsx";

        using (var document = SpreadsheetDocument.Open(excelFileName, false))
        {
            var workbookPart = document.WorkbookPart;
            var stringTable = workbookPart.GetPartsOfType().FirstOrDefault();
            var sheet = workbookPart.Workbook.Descendants().Where(sheetItem => sheetItem.Name == "image").FirstOrDefault();
            var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id);
            var cells = worksheetPart.Worksheet.Descendants();
            (...省略...)

このコードでは、「image」という名前のシートのセル情報を全て取得しています。
取得したセル情報は、変数「cells」に格納されています。

4.2.セル内の情報の取得

各セルに設定された情報は、そのSharedStringItemオブジェクトとして取得します。
SharedStringItemオブジェクトは、下記のようにして取得します。

foreach (var cell in cells)
{
    if (cell.DataType.Value != CellValues.SharedString)
    {
        continue;
    }

    string cellInnerText = string.Empty;
    int itemIndex = int.Parse(cell.InnerText);
    SharedStringItem item = workbookPart.SharedStringTablePart.SharedStringTable.Elements().ElementAt(itemIndex);
    (...省略...)

上記コードの「cells」は、imageシートのセル情報を格納したcellsと同じです。
SharedStringItemの変数itemに、1つのセルの文字列情報が格納されています。
なお、このitemが前述のエクセルの内容を見える化したXMLのsi要素に対応します。

4.3.セル内のstrike情報の取得

取り消し線の有無はstrike要素の有無によって、判断が可能です。
そのために、変数itemのChildElements(si要素の子要素)、およびその孫要素を解析して、strike要素の有無を調べます。
コードは、下記のようになります。

if (item.HasChildren)
{
    //Runクラス(r要素)
    var childrenTypeRun = item.ChildElements.Where(itemChildren => itemChildren is Run);
    if (0 < childrenTypeRun.Count())
    {
        foreach (var childTypeRun in childrenTypeRun)
        {
            //RunPropertyクラス(rPr要素)の有無を確認する。
            var childrenTypeRunProperty = childTypeRun.ChildElements.Where(childTypeRunItem => childTypeRunItem is RunProperties);
            if (0 < childrenTypeRunProperty.Count())
            {
                foreach (var childTypeRunProperty in childrenTypeRunProperty)
                {
                    //Strikeクラス(strike要素)の有無の判定
                    var childrenTypeStrike = childTypeRunProperty.ChildElements.Where(childTypeRunPropertyItem => childTypeRunPropertyItem is Strike);
                    if (0 < childrenTypeStrike.Count())
                    {
                        //取り消し線アリ:スキップする。
                        continue;
                    }
                    else
                    {
                        //取り消し線ナシ:RunPropertyの文字列を、有効な文字列と判断して、保持する。
                        cellInnerText += childTypeRun.InnerText;
                    }
                }
            }
            else
            {
                /*
                    * RunPropertiesクラス(rPr要素)が無い場合、Runクラスの文字列がセル内の文字列の一部となる。
                    */
                cellInnerText += childTypeRun.InnerText;
                continue;
            }

        }
    }
(...省略...)

上記のコードの大まかな処理内容を書くと、以下のようになります。

  1. r要素の子要素から、rPr要素を取得
  2. rPr要素の子要素から、strike要素を取得
    • strike要素が無い場合は、取り消し線で消された文字列ではない
      →そのまま取得
    • strike要素がある場合は、取り消し線で消された文字列
      →スキップ

これにより、セル内の文字列から、取り消し線で消されていない文字列のみを抜き出すことができます。
エクセルのXMLの各要素とC#のクラスの対応を、示しておきます。

要素 クラス
r Run
rPr RunProperties
strike Strike

5.実行

ここまで書いてきたコードを実行すると、以下のようになります。
入力:
cs_openxml_remove_strikethrough_003
出力:
cs_openxml_remove_strikethrough_004
一番左がセルの名前、真ん中がセル内の取り消し線で消されていない文字列、カッコ内がセル内の生文字列です。
(カッコ内の文字列については、漢字の読み仮名が表示されていますが、テーマとは関係ないので無視しています。)
出力を確認すると、セル内の文字列から「取り消し線で消された文字列」を削除した文字列が取得できていることが確認できます。

6.まとめ

今回は、OpenXmlを使用して、エクセルのセル内の文字列から、「取り消し線で消された文字列」を取り除いた文字列を取得する処理について書きました。
これまでは、手動で取り消し線で消された文字列は1つ1つ手動で消していましたが、これにより一気に目的の文字列を取得できるようになりました。
この処理を用いることで、少しでも作業の効率が上がればいいな、と思います。

なお、本エントリで紹介したコードは、処理の内容を示すことを優先してエラー処理を削除しています。
全コードは、GitHubで公開しているコード(完全版)を参照してください。
ではっ!

ex.ぼやき

なんで、変更履歴/修正履歴をドキュメント内に残したりしてるんだろう…?
そんなの、バージョン管理/構成管理のツール使えばいいのに。
フリーで使用可能なツール、山ほどあるし。
うーむ。
ワカラン。
(変更履歴/修正履歴をドキュメント内に残すメリット、どなたか教えていただけないでしょうか。)

ex2.続き

セル内の「取り消し線で消された文字列」の削除を簡単にしてみた