System.IO.Packaging.ZipPackageを使ってMS Officeのファイルの中の画像を圧縮する[PowerShell]

# はじめに

.NETには[`System.IO.Packaging.ZipPackage`](https://docs.microsoft.com/ja-jp/dotnet/api/system.io.packaging.zippackage?view=netframework-4.8 "ZipPackage クラス (System.IO.Packaging) | Microsoft Docs")というクラスがあり、
このクラスを使うとことで、外部ライブラリ無しでもMS Officeのファイルなどに使われているOOXML形式の中身を処理できる。
(もっとも、ちゃんとやろうとすると大変なので、使えるならそれ用のライブラリを使った方がよい)

この記事は、自身の`ZipPackage`の勉強として作成した、
MS Office ファイルの中の画像を`System.Drawing`を使用して圧縮する関数の紹介となる。

# 動作確認環境

- Windows PowerShell 5.1 (Windows 10 プリインストール版)
  - 使用ライブラリ的に、PowerShell v7 でも動作するはずだが未検証
- Microsoft Office 365 で作成した以下の種類のファイル
  - .xlsx (Excel)
  - .pptx (PowerPoint)
  - .docx (Word)

## 注意事項

(少なくともWIndows版の)MS Office はファイルの中に含んでいる画像について以下のような挙動を示すことを利用しています。

- ファイルの拡張子と中身の形式が異なっても、画像であれば読み込んでくれる
- ファイルには図形の大きさが保存されており、画像は図形に合わせて自動拡縮される

他のソフトや環境では、意図した結果とならない可能性があるため、
この記事の内容を実行した結果については、自己責任でお願いします。


# 作成した関数

## 使い方・仕様

```powershell
Compress-ImageInMsoFile -LiteralPath '対象のMS Officeのファイルのパス' -Quality 'Jpegの品質 1~100' -Ratio '画像のリサイズ比率 0.1~1'
```

`LiteralPath`に指定されたファイルを`○○.Compress.拡張子`にコピー(ファイルが存在する場合は上書き)した上で、
コピーしたファイルの中の画像を、Jpeg形式で保存し直す。

Jpeg形式で保存し直すときに`Quality`と`Ratio`の情報を使用し、現在のファイルサイズより小さくなった場合(圧縮できた場合)のみ、中身を書き換える。

また、元の画像が Png などで透過されている場合は、その情報が消えてしまうため注意が必要。

## コード

```powershell:Compress-ImageInMsoFile.ps1
using namespace System.IO
using namespace System.Management.Automation

using assembly  WindowsBase
using namespace System.IO.Packaging

using assembly  System.Drawing
using namespace System.Drawing
using namespace System.Drawing.Imaging

function Compress-ImageInMsoFile {
<# 
.SYNOPSIS
MS Office などの中に含まれる画像ファイルを圧縮します。
.DESCRIPTION 
MS Office などの中に含まれる画像ファイルを Jpeg として再保存することで圧縮します。
圧縮後のファイルは「元のファイル名.Compress.拡張子」のファイルとなります。
#>
    [CmdletBinding(
        # TODO:対応した方が親切。
        # SupportsShouldProcess = $true, ConfirmImpact = [ConfirmImpact]::Medium
    )]
    [OutputType([System.IO.FileInfo])]
    Param(
        # 画像を圧縮する MS Office のファイル名を指定します。圧縮後のファイルは「指定したファイル名.Compress.拡張子」のファイルとなります。
        [Parameter(
            Mandatory = $true, 
            ValueFromPipeline = $true, 
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = '画像を圧縮したい MS Office のファイル名を指定して下さい。'
        )]
        [Alias('FullName')]
        [ValidateNotNullOrEmpty()]
        [string[]]$LiteralPath
        ,
        # 圧縮品質を指定します。数値が大きいほど品質が高く、最大値は 100、最小値は 1 です。
        # https://docs.microsoft.com/ja-jp/dotnet/api/system.drawing.imaging.encoder.quality
        [ValidateRange(1, 100)]
        [long]$Quality = 70
        ,
        # 画像の縮小比率を指定します。既定値は 1(変更無し) で、最小値は 0.1(10 分の 1) です。
        [ValidateRange(0.1, 1)]
        [double]$Ratio = 1
    )
    
    Begin {
        # foreachの変数にISE上で補完を効かせるため。
        [string]$p = [ZipPackagePart]$part = $null

        # Jpeg圧縮用の各種設定。
        [ImageCodecInfo]$jpgCodec = 
            [ImageCodecInfo]::GetImageEncoders() |
                Where-Object -Property MimeType -CEQ -Value 'image/jpeg' |
                Select-Object -First 1
    
        [Encoder]$qualityEncoder = [Encoder]::Quality
        [EncoderParameters]$encodeParams = [EncoderParameters]::new(1)
        $encodeParams.Param[0] = [EncoderParameter]::new($qualityEncoder, $Quality)
    }

    Process {
        # ファイルが存在するか＆フルパスに変換。
        foreach ($p in (Resolve-Path -LiteralPath $LiteralPath -ErrorAction Stop).ProviderPath) {
            # 圧縮後のファイル名を作成(手抜き)。
            [string]$compressedFileName = [Path]::ChangeExtension(
                $p,
                'Compress' + [Path]::GetExtension($p)
            )
            [FileInfo]$resultFile = Copy-Item -LiteralPath $p -Destination $compressedFileName -PassThru

            
            # コピー後のファイル
            [ZipPackage]$pkg = [ZipPackage]::Open(
                $compressedFileName,
                [FileMode]::Open,
                [FileAccess]::ReadWrite,
                [FileShare]::None
            )
            try <# Dispose for $pkg #> {
                foreach ($part in $pkg.GetParts()) {
                    if (-not $part.ContentType.StartsWith('image/')) {
                        continue
                    }
                    [MemoryStream]$originalImgStream = [MemoryStream]::new()
                    try <# Dispose for $originalImgStream #> { 
                        # 元の画像を一旦メモリ上にコピー。
                        [Stream]$sr = $part.GetStream([FileMode]::Open, [FileAccess]::Read)
                        try <# Dispose for $sr #> {
                            $sr.CopyTo($originalImgStream)
                        } finally {$sr.Dispose()}

                        [Image]$img = [Image]::FromStream($originalImgStream)
                        [MemoryStream]$compImgStream = [MemoryStream]::new()
                        try <# Dispose for $img, $compImgStream #> {
                            if ($Ratio -ne 1) {
                                # 縮小した画像を作成。
                                $img = [Bitmap]::new($img, [int]($img.Width * $Ratio), [int]($img.Height * $Ratio))
                            }
                            # 圧縮して保存。
                            $img.Save($compImgStream, $jpgCodec, $encodeParams)
                            
                            if ($compImgStream.Length -lt $originalImgStream.Length) {
                                # 結果が圧縮前より小さくなっていれば、ZipPackagePartの中身も上書き。
                                [Stream]$sw = $part.GetStream([FileMode]::Create, [FileAccess]::Write)
                                try <# Dispose for $sw #> {
                                    $compImgStream.WriteTo($sw)
                                } finally {$sw.Dispose()}
                            }
                        } finally {$img.Dispose(); $compImgStream.Dispose()}
                    } finally {$originalImgStream.Dispose()}
                }
            } finally {$pkg.Dispose()}

            # 圧縮後のファイルの情報を出力。
            $PSCmdlet.WriteObject($resultFile)
        }
    }

    End {
        # TODO:実行されない場合もあるのでその対処。
        $encodeParams.Dispose()
    }
}
```

## 実行例

以下のようなファイル(Sample.xlsx とする)を用意し、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142842/2464c1f5-8916-6ca5-c3a2-25f07fe11139.png)

以下を実行すると(品質最低＆画像サイズ半分)、

```powershell
Compress-ImageInMsoFile .\Sample.xlsx -Quality 1 -Ratio 0.5
```

以下のような結果となる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142842/6ca91c98-5c7d-362f-8842-83154eda9228.png)

この時、ファイルサイズは`19181`→`11399`となり小さくなる。

## その他はまったこと

`ZipPackagePart`から2回`GetStream`をしているのが気になったので、1回にできないか試してみたが、.NETの`System.IO.Stream`の扱いに慣れていないのもあり、上手くいかなかった。

# 参考ページ

> [ZipPackage クラス (System.IO.Packaging) | Microsoft Docs](https://docs.microsoft.com/ja-jp/dotnet/api/system.io.packaging.zippackage?view=netframework-4.8 "ZipPackage クラス (System.IO.Packaging) | Microsoft Docs")
> [Encoder.Quality フィールド (System.Drawing.Imaging) | Microsoft Docs](https://docs.microsoft.com/ja-jp/dotnet/api/system.drawing.imaging.encoder.quality?view=netframework-4.8 "Encoder.Quality フィールド (System.Drawing.Imaging) | Microsoft Docs")