[PowerShell] Word를 사용하지 않고 Docx 파일에서 텍스트/댓글/굵게/표기 정보를 꺼냅니다(2021년 버전)

57221 단어 PowerShellWordtech
이전에 쓴 내용 그 후로 작은 모형을 가지고 놀다가 원형을 거의 남기지 않은 것 같아서 기사를 다시 썼어요.
환경:
> $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.Filesystem Add-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.xmlSystem.IO.StreamReader 까닭에 챙기는 것[System.IO.Compression.Zipfile]::OpenRead() 등을 잊지 않도록 주의하세요.
    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 내의 불필요한 잡음 제거 방법


워드 버전의 차이를 흡수하기 위해 Xml에 Close()라는 요소stack overflow를 기술했다고 한다.
그대로 두면 문자 메시지가 반복되기 때문에 미리 아래의 방법으로 제거한다.
또한 <mc:FallBack></mc:FallBack> 요소에 공백 문자가 있으면 <w:t></w:t>의 내용(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);
        }
    }
반환값은 대상으로 파일을 열 때xml:space="preserve" 속성과 같다.
Word에 대한 설명 정보는 응용 프로그램Status에서 확인할 수 있습니다.
이것은 본래 평론이 없기 때문에 word/comments.xml도 그 판단에 회답할 것이다.
    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로 하는 것보다 정규적인 표현으로 매칭하는 것이 더욱 쉽다.
단락 정보 추출Status
    static [string[]] GetParagraphs ([string]$content) {
        $m = [regex]::Matches($content, "<w:p [^>]+?>.+?</w:p>")
        return $m.Value
    }
Range<w:p></w:p>의 추출
VBA에서 자주 접촉하는 거.
Word의 버전에 따라 형식도 미묘하게 다르다.
    static [string[]] GetRanges ([string]$content) {
        $m = [regex]::Matches($content, "(<w:r>.+?</w:r>)|(<w:r w:[^>]+?>.+?</w:r>)")
        return $m.Value
    }
텍스트 정보<w:r></w:r>의 추출
상기 두 가지 방법으로 요소를 축소한 후 최종 텍스트 정보는<w:t></w:t>의 내용이다.
이것을 뽑으면 워드 화면에서 본 내용과 같은 것을 얻을 수 있을 것이다.
    static [string] GetText ([string]$nodeContent) {
        $m = [regex]::Matches($nodeContent, "(?<=<w:t>).+?(?=</w:t>)")
        return ($m.Value -replace "&amp;", "&") -join ""
    }
주석 정보 추출
같은 생각으로 정보를 평론해도 정규 표현식으로 추출할 수 있다.
귀환 값에 상기 <w:t></w:t> 를 사용하면 주석의 텍스트 정보를 얻을 수 있습니다.
    static [string[]] GetComments ([string]$content) {
        $m = [regex]::Matches($content, "<w:comment w:[^>]*?>.*?</w:comment>")
        return $m.Value
    }

원본 정보 축소


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 {}
}

좋은 웹페이지 즐겨찾기