📝

【PowerShell】Word なしで Docx ファイルからテキスト/コメント/太字/マーカー情報を取り出す[2021年版]

2021/10/17に公開

以前に書いた内容 をあれからチマチマいじくり回し、だいぶ原型を留めない感じになってきたのであらためて記事にしてみました。

環境:

> $PSVersionTable

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

クラス作成

Docx 内の Xml を読み取って整形するためのクラスです。
各メソッドの解説は後ほど。


if ("System.IO.Compression.Filesystem" -notin ([System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object{$_.GetName().Name})) {
    Add-Type -AssemblyName System.IO.Compression.Filesystem
}

class Docx {

    static [bool] IsOpened ([string]$path) {
        $stream = $null
        $inAccessible = $false
        try {
            $stream = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
        }
        catch {
            $inAccessible = $true
        }
        finally {
            if($stream) {
                $stream.Close()
            }
        }
        return $inAccessible
    }

    static [string] Cat ([string]$path, [string]$relPath) {
        $content = ""
        $archive = [IO.Compression.Zipfile]::OpenRead($path)
        $entry = $archive.GetEntry($relPath)
        if ($entry) {
            $stream = $entry.Open()
            $reader = New-Object IO.StreamReader($stream)
            $content = $reader.ReadToEnd()
            $reader.Close()
            $stream.Close()
        }
        $archive.Dispose()
        return $content
    }


    static [string] RemoveNoise ([string]$s) {
        return $s.Replace("<w:t xml:space=`"preserve`">", "<w:t>") -replace "<mc:FallBack>.+?</mc:FallBack>"
    }

    static [PSCustomObject] GetXml ([string]$path) {
        if ([Docx]::IsOpened($path)) {
            return [PSCustomObject]@{
                "Status" = "FILEOPENED";
                "Xml" = "";
            }
        }
        $content = [docx]::Cat($path, "word/document.xml")
        return [PSCustomObject]@{
            "Status" = "OK";
            "Xml" = [Docx]::RemoveNoise($content);
        }
    }

    static [PSCustomObject] GetCommentsMarkup ([string]$path) {
        if ([Docx]::IsOpened($path)) {
            return [PSCustomObject]@{
                "Status" = "FILEOPENED";
                "Xml" = "";
            }
        }
        $content = [Docx]::Cat($path, "word/comments.xml")
        return [PSCustomObject]@{
            "Status" = ($content)? "OK" : "NOCOMMENT";
            "Xml" = $content;
        }
    }

    static [string[]] GetParagraphs ([string]$content) {
        $m = [regex]::Matches($content, "<w:p [^>]+?>.+?</w:p>")
        return $m.Value
    }

    static [string[]] GetRanges ([string]$content) {
        $m = [regex]::Matches($content, "(<w:r>.+?</w:r>)|(<w:r w:[^>]+?>.+?</w:r>)")
        return $m.Value
    }

    static [string[]] GetComments ([string]$content) {
        $m = [regex]::Matches($content, "<w:comment w:[^>]*?>.*?</w:comment>")
        return $m.Value
    }

    static [string] GetText ([string]$nodeContent) {
        $m = [regex]::Matches($nodeContent, "(?<=<w:t>).+?(?=</w:t>)")
        return ($m.Value -replace "&amp;", "&") -join ""
    }

    static [string[]] FilterNode ([string[]]$nodes, [string]$pattern) {
        $arr = New-Object System.Collections.ArrayList
        $sb = New-Object System.Text.StringBuilder
        foreach ($n in $nodes) {
            if ($n -match $pattern) {
                $sb.Append($n) > $null
            }
            else {
                $s = $sb.ToString()
                if ($sb.Length) {
                    $arr.Add($s) > $null
                }
                $sb.Clear()
            }
        }
        return $arr
    }

}


静的メソッドの寄せ集めでクラス本来の良さを発揮できていない気もしますがそこはご容赦を……(詳しい方のアドバイスお待ちしています!)。

詳細の解説

事前準備

Docx ファイルの実態は zip なので、その中身にアクセスするために System.IO.Compression.FilesystemAdd-Type しておきます。

そのままだと PowerShell 自体をリロードするたびにアセンブリを読み込む形になってスマートではないので、すでに読み込まれているかを事前にチェックしています。

if ("System.IO.Compression.Filesystem" -notin ([System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object{$_.GetName().Name})) {
    Add-Type -AssemblyName System.IO.Compression.Filesystem
}

ファイルが開かれているか判定するメソッド

Docx ファイルが開かれている場合は中身を読むことができないので、まずはその判定。

    static [bool] IsOpened ([string]$path) {
        $stream = $null
        $inAccessible = $false
        try {
            $stream = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
        }
        catch {
            $inAccessible = $true
        }
        finally {
            if($stream) {
                $stream.Close()
            }
        }
        return $inAccessible
    }

Docx(zip)内部にアクセスするメソッド

Docx の拡張子を zip にして解凍するとわかるように、 word というサブディレクトリ内の document.xml に文書のテキスト情報が含まれています。
System.IO.StreamReader[System.IO.Compression.Zipfile]::OpenRead() を使うので Close() などの後片付けを忘れないように要注意です。

    static [string] Cat ([string]$path, [string]$relPath) {
        $content = ""
        $archive = [IO.Compression.Zipfile]::OpenRead($path)
        $entry = $archive.GetEntry($relPath)
        if ($entry) {
            $stream = $entry.Open()
            $reader = New-Object IO.StreamReader($stream)
            $content = $reader.ReadToEnd()
            $reader.Close()
            $stream.Close()
        }
        $archive.Dispose()
        return $content
    }

Xml 内の余計なノイズを除去するメソッド

Word のバージョンによる差異を吸収するために、Xml 内には <mc:FallBack></mc:FallBack> という要素が記述されているそうです(stack overflow)。
放っておくとテキスト情報が重複するため、事前に下記のメソッドで取り除いておきます。

また、 <w:t></w:t> 要素内に空白文字が含まれている場合、xml:space="preserve" という内容が追加されます(Xml パース時のスペース処理の都合なんですかね)。
後述のように、正規表現で文字列を抽出するにあたりこの部分が厄介だったので同じく除去しています。

    static [string] RemoveNoise ([string]$s) {
        return $s.Replace("<w:t xml:space=`"preserve`">", "<w:t>") -replace "<mc:FallBack>.+?</mc:FallBack>"
    }

メインのメソッド

これまでに書いた内容を使い、Docx ファイルの Xml をテキストとして抜き出すのが下記メソッドです。主にこれを使い回す形になります。

    static [PSCustomObject] GetXml ([string]$path) {
        if ([Docx]::IsOpened($path)) {
            return [PSCustomObject]@{
                "Status" = "FILEOPENED";
                "Xml" = "";
            }
        }
        $content = [docx]::Cat($path, "word/document.xml")
        return [PSCustomObject]@{
            "Status" = "OK";
            "Xml" = [Docx]::RemoveNoise($content);
        }
    }

返り値はオブジェクトとして、ファイルが開かれていた場合は Status プロパティで示すようにしています。

応用で、Word のコメント情報は word/comments.xml から取得できます。
こちらはそもそもコメント自体が無いこともあるので Status でその判定も返すようにしています。

    static [PSCustomObject] GetCommentsMarkup ([string]$path) {
        if ([Docx]::IsOpened($path)) {
            return [PSCustomObject]@{
                "Status" = "FILEOPENED";
                "Xml" = "";
            }
        }
        $content = [Docx]::Cat($path, "word/comments.xml")
        return [PSCustomObject]@{
            "Status" = ($content)? "OK" : "NOCOMMENT";
            "Xml" = $content;
        }
    }

Xml 内部情報を抜き出すメソッド

Docx の Xml 構造は凄まじく入り組んでいたので、Xml としてパースするよりも正規表現でマッチさせたほうが楽でした。

段落情報( <w:p></w:p> )の抽出
    static [string[]] GetParagraphs ([string]$content) {
        $m = [regex]::Matches($content, "<w:p [^>]+?>.+?</w:p>")
        return $m.Value
    }
Range( <w:r></w:r> )の抽出

VBA で頻繁に触れることになるアレです。
Word のバージョンによって微妙に書式が異なる模様。

    static [string[]] GetRanges ([string]$content) {
        $m = [regex]::Matches($content, "(<w:r>.+?</w:r>)|(<w:r w:[^>]+?>.+?</w:r>)")
        return $m.Value
    }
テキスト情報( <w:t></w:t> )の抽出

上記2つのメソッドで要素を絞り込んだあと、最終的なテキスト情報は <w:t></w:t> の中身になります。
これを抜き出せば、(おそらく)Word の画面上で見えている内容と同じものが取得できます。

    static [string] GetText ([string]$nodeContent) {
        $m = [regex]::Matches($nodeContent, "(?<=<w:t>).+?(?=</w:t>)")
        return ($m.Value -replace "&amp;", "&") -join ""
    }
コメント情報の抽出

同じ考え方でコメント情報も正規表現で抜き出せます。
返り値に対して上記の GetText() を使うことでコメントのテキスト情報が入手できます。

    static [string[]] GetComments ([string]$content) {
        $m = [regex]::Matches($content, "<w:comment w:[^>]*?>.*?</w:comment>")
        return $m.Value
    }

Range 情報の絞り込み

Word の太字マーカーといった装飾は上記 Range 単位で記録されているため、そのまま抜き出すとぶつ切りの情報になります(Word 上ではひと繋がりの文字列でも、複数の Range となっていることが多い)。

そこで、同一条件にマッチする連続した要素ごとに配列にまとめ直すメソッドも作ってみました。

    static [string[]] FilterNode ([string[]]$nodes, [string]$pattern) {
        $arr = New-Object System.Collections.ArrayList
        $sb = New-Object System.Text.StringBuilder
        foreach ($n in $nodes) {
            if ($n -match $pattern) {
                $sb.Append($n) > $null
            }
            else {
                $s = $sb.ToString()
                if ($sb.Length) {
                    $arr.Add($s) > $null
                }
                $sb.Clear()
            }
        }
        return $arr
    }

実際の使い方

テキスト情報の抽出

function Get-DocxTextContent {
    <#
        .EXAMPLE
        Get-DocxTextContent .\test.docx
        .EXAMPLE
        ls | Get-DocxTextContent
    #>
    param (
        [parameter(ValueFromPipeline = $true)]$inputObj
    )
    begin {}
    process {
        $fileObj = Get-Item $inputObj
        if ($fileObj.Extension -ne ".docx") {
            return
        }
        $fullPath = $fileObj.FullName
        $markup = [Docx]::GetXml($fullPath)
        $lines = New-Object System.Collections.ArrayList
        [Docx]::GetParagraphs($markup.Xml) | ForEach-Object {
            $lines.Add([Docx]::GetText($_)) > $null
        }
        return [PSCustomObject]@{
            Name = $fileObj.Name;
            Status = $markup.Status;
            Lines = $lines;
        }
    }
    end {}
}

コメント情報の抽出

function Get-DocxComment {
    <#
        .EXAMPLE
        Get-DocxComment ./hoge.docx
        .EXAMPLE
        ls | Get-DocxComment
    #>
    param (
        [parameter(ValueFromPipeline = $true)]$inputObj
    )
    begin {}
    process {
        $fileObj = Get-Item $inputObj
        if ($fileObj.Extension -ne ".docx") {
            return
        }
        $markup = [Docx]::GetCommentsMarkup($fileObj.FullName)
        $comments = New-Object System.Collections.ArrayList
        if ($markup.Status -eq "OK") {
            [Docx]::GetComments($markup.Xml) | ForEach-Object {
                $comments.Add(
                    [PSCustomObject]@{
                        "Author" = [regex]::Match($_, "(?<=w:author=).+?(?= w.date)").value;
                        "Text" = $_ -replace "<[^>]+?>";
                    }
                ) > $null
            }
        }
        return [PSCustomObject]@{
            "Name" = $fileObj.Name;
            "Status" = $markup.Status;
            "Comments" = $comments;
        }
    }
    end {}
}

太字情報の抽出

function Get-DocxBoldString {
    param (
        [parameter(ValueFromPipeline = $true)]$inputObj
    )
    begin {}
    process {
        $fileObj = Get-Item $inputObj
        if ($fileObj.Extension -ne ".docx") {
            return
        }
        $fullPath = $fileObj.FullName
        $markup = [Docx]::GetXml($fullPath)
        $ranges = [Docx]::GetRanges($markup.Xml)
        $bolds = New-Object System.Collections.ArrayList
        [Docx]::FilterNode($ranges, "<w:b/>") | ForEach-Object {
            $bolds.Add([Docx]::GetText($_)) > $null
        }
        return [PSCustomObject]@{
            "Name" = $fileObj.Name;
            "Status" = $markup.Status;
            "Decorated" = $bolds;
        }
    }
    end {}
}

マーカー情報の抽出

function Get-DocxMarkeredString {
    param (
        [parameter(ValueFromPipeline = $true)]$inputObj
        ,[string]$color = "yellow"
    )
    begin {}
    process {
        $fileObj = Get-Item $inputObj
        if ($fileObj.Extension -ne ".docx") {
            return
        }
        $fullPath = $fileObj.FullName
        $markup = [Docx]::GetXml($fullPath)
        $ranges = [Docx]::GetRanges($markup.Xml)
        $markers = New-Object System.Collections.ArrayList
        [Docx]::FilterNode($ranges, "<w:highlight w:val=`"$($color)`"/>") | ForEach-Object {
            $markers.Add([Docx]::GetText($_)) > $null
        }
        return [PSCustomObject]@{
            "Name" = $fileObj.Name;
            "Status" = $markup.Status;
            "Decorated" = $markers;
        }
    }
    end {}
}

Discussion