📈

【PowerShell】Word に埋め込まれたリンク先不明のグラフからデータを抽出する

2022/06/10に公開

Microsoft Word 七不思議の1つ、それはリンク先がなくなってもグラフを表示し続けるところ……。

データを再利用しようとしてこんなメッセージが出てガックリしたことは数知れず。

仕方なく手入力しようと データラベルの追加 をクリックして、こんな桁数の数字が表示された日には絶望すら覚えました。

しかしこうして数値を表示できているわけですから、何かしらの構造的なデータは埋め込まれているのは間違いありません。

そこで今回は PowerShell を使って Word データをほじくり返し、グラフに表示されている値を抽出してみようと思います。

成果

Word を開き、調べたいグラフをクリックして選択した状態でコマンドレットを実行すると、コンソール上にグラフ内のデータ系列の値をオブジェクトとして取得できるようになります。

環境:

> $PSVersiontable

Name                           Value
----                           -----
PSVersion                      7.2.4
PSEdition                      Core
GitCommitId                    7.2.4
OS                             Microsoft Windows 10.0.19044
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

事前準備:開かれている Word を操作できるようにする

バージョン6以降で Core の名を冠した PowerShell はクロスプラットフォーム化の代償として、 [System.Runtime.InteropServices.Marshal]::GetActiveObject() を使った「現在開かれている Office ソフトの操作」ができなくなっています。

バージョン5までの Windows PowerShell であればこのステップは不要です。

PowerShell 6.0 以降でこの機能を有効化するには、Add-Type で C# のコードを埋め込んでやればOKです。

if (-not ('Pwsh.Marshal' -as [type]))
{Add-Type -Namespace "Pwsh" -Name "Marshal" -MemberDefinition @'

internal const String OLEAUT32 = "oleaut32.dll";
internal const String OLE32 = "ole32.dll";

public static Object GetActiveObject(String progID)
{
    Object obj = null;
    Guid clsid;

    try
    {
        CLSIDFromProgIDEx(progID, out clsid);
    }
    catch (Exception)
    {
        CLSIDFromProgID(progID, out clsid);
    }

    GetActiveObject(ref clsid, IntPtr.Zero, out obj);
    return obj;
}

[DllImport(OLE32, PreserveSig = false)]
private static extern void CLSIDFromProgIDEx([MarshalAs(UnmanagedType.LPWStr)] String progId, out Guid clsid);

[DllImport(OLE32, PreserveSig = false)]
private static extern void CLSIDFromProgID([MarshalAs(UnmanagedType.LPWStr)] String progId, out Guid clsid);

[DllImport(OLEAUT32, PreserveSig = false)]
private static extern void GetActiveObject(ref Guid rclsid, IntPtr reserved, [MarshalAs(UnmanagedType.Interface)] out Object ppunk);

'@ }

SilkyFowl 氏に深謝! 😭

https://qiita.com/SilkyFowl/items/e57f1fb165cf2ea33092

その上で下記のようなコマンドレットを作ってやれば、あとは VBA の文法で現在開いている文書を操作できるようになります。

function Get-ActiveWordApp {
    $office = [Pwsh.Marshal]::GetActiveObject("Word.Application")
    try {
        if ($office.Documents.Count -lt 1) {
            return $null
        }
        return $office
    }
    catch {
        return $null
    }
}

コンソールから ActiveDocument で文書の情報を取得したり、

> Get-ActiveWordApp | sv w;
$w.ActiveDocument.name # 開いている文書の名前が出力される

Selection で選択範囲を扱えます。

> Get-ActiveWordApp | sv w;
$w.Selection.font.bold=$true # 選択している文字が太字になる

VBA のイミディエイトウインドウと違って debug.print() と都度入力しなくていいのが最高ですね。

メイン処理

# VBA と同じく配列のインデックスは1から始まるので注意!
function Get-EmbeddedDataOnActiveWordDocument {
    $wd = Get-ActiveWordApp
    if (-not $wd) { return }

    $chart = $null
    if ($wd.Selection.Range.InlineShapes.Count -eq 1 -and $wd.Selection.Range.InlineShapes(1).HasChart) {
        # グラフが「行内」のとき
        $chart = $wd.Selection.Range.InlineShapes(1).Chart
    }
    if ($wd.Selection.ShapeRange.Count -eq 1 -and $wd.Selection.ShapeRange(1).HasChart) {
        # グラフが「行内」ではないとき
        $chart = $wd.Selection.ShapeRange(1).Chart
    }
    if (-not $chart) {
        "グラフを1つ選択した状態で実行してください!" | Write-Host -ForegroundColor Red
        return
    }

    $nSeries = $chart.SeriesCollection().Count
    1..$nSeries | ForEach-Object {
        $label = $chart.SeriesCollection($_).Name
        $xVals = $chart.SeriesCollection($_).XValues
        $vals = $chart.SeriesCollection($_).Values
        $data = 1..$xVals.Count | ForEach-Object {
            return [PSCustomObject]@{
                "X" = $xVals.get($_);
                "Value" = $vals.get($_);
            }
        }
        return [PSCustomObject]@{
            "Label" = $label;
            "Data" = $data;
        }
    }

}

グラフ内のデータ系列(折れ線グラフなら折れ線の1本1本)は SeriesCollection オブジェクトです。

Chart.SeriesCollection(1) で1番目の系列を取得できるようなメソッド形式になっていて、Chart.SeriesCollection.Count のようにプロパティから系列数を取得することができず苦労しました( Chart.SeriesCollection().Count と引数なしで呼び出せばいいなんて所見殺しですよね…?)。

Discussion