🛸

PowerShellからRTF(RichTextFormat)を操作して装飾した文字列をコピーする

2024/09/13に公開

環境:

> $psversiontable

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

実演

Add-Type -AssemblyName "System.Windows.Forms"
$rtf = "{\rtf{\colortbl;\red0\green0\blue0;\red255\green255\blue0;}これによって{\cf1\i\b\highlight2 色つき}になります。}"
[System.Windows.Forms.Clipboard]::SetText($rtf, [System.Windows.Forms.TextDataFormat]::Rtf)

上記のコードを実行して、

> cat .\copy.ps1
Add-Type -AssemblyName "System.Windows.Forms"
$rtf = "{\rtf{\colortbl;\red0\green0\blue0;\red255\green255\blue0;}これによって{\cf1\i\b\highlight2 色つき}になります。}"
[System.Windows.Forms.Clipboard]::SetText($rtf, [System.Windows.Forms.TextDataFormat]::Rtf)

> .\copy.ps1

それからWordを開いて「貼り付け」操作をすると 色つき の部分に黄色のハイライトが入って書体もイタリックボールドになります。

何が起きているのか

RTF(RichTextFormat)形式でデータをクリップボードに格納しています。

RTF構造

上記のコード内のRTFに改行とインデントを追加してみます。

{\rtf
    {\colortbl;
        \red0\green0\blue0;
        \red255\green255\blue0;
    }
    これによって{\cf1\i\b\highlight2 色つき}になります。
}

ここで重要なのが \colortbl
事前にカラーテーブルを決めておいて、以降のマークアップではテーブル内のインデックスで色を指定します(フォントなども同様に \fonttbl を使う)。

上記では、

  • \cf1 で文字色(1番目の黒色)
  • \highlight2 でマーカーの色(2番目の黄色)

を決めています。
インデックスが1始まりのように見えますが、0が「自動」を意味するようです。

注意点①:指定できる \colortbl は1種類だけ

{\rtf
    {\colortbl;
        \red0\green0\blue0;
        \red255\green255\blue0;
    }
    これによって{\cf1\i\b\highlight2 色つき}になります。
    {\colortbl;
        \red0\green0\blue0;
        \red0\green255\blue255;
    }
    2つめの{\cf1\i\b\highlight2 色}はどうなるのでしょうか。
}

複数の \colortbl 宣言がある場合は最初に登場したものが利用されるようです。
装飾箇所ごとに宣言するのではなく、最初に使う色はテーブルとして宣言しておき、インデックスで指定します。

注意点②:非ASCII文字の扱い

{\rtf
    {\colortbl;
        \red0\green0\blue0;
        \red255\green255\blue0;
    }
    これによって{\cf1\i\b\highlight2 色つき}になりま〰す。
}

(U+3030)を入れてみると ? に文字化けします。どうやらShift-JISの範囲外はそのままでは表現できない様子。

{\rtf
    {\colortbl;
        \red0\green0\blue0;
        \red255\green255\blue0;
    }
    これによって{\cf1\i\b\highlight2 色つき}になりま\u12336?す。
}

\u12336? のように文字コードを指定すれば解決します。

実践

> gcb
あい▲う▲えお
か▲き▲くけこ
さし▲すせ▲そ

> gcb | ConvertTo-RtfInsideSymbol | Set-ClipboardAsRtf

  1. テキストエディタを使って装飾したい部分を で囲む
  2. コピーする
  3. PowerShellの gcbGet-Clipboardのエイリアス)を使って読み込む
  4. ▲で囲まれた部分をイタリック&黄色マーカーにする

という一連の操作をコマンドにしてみました。
Wordでワイルドカードをゴニョゴニョする必要がなくなります。時短!

クラス定義

colortbl定義などのツールをクラスにまとめておきます。

`RtfUtil` クラス全体(長いので折りたたみ)
class RtfUtil {
    RtfUtil() {}

    static $table = [ordered]@{
        "Black" = @(0, 0, 0);
        "Blue" = @(0, 0, 255);
        "Cyan" = @(0, 255, 255);
        "Green" = @(0, 255, 0);
        "Magenta" = @(255, 0, 255);
        "Red" = @(255, 0, 0);
        "Yellow" = @(255, 255, 0);
        "White" = @(255, 255, 255);
        "DarkBlue" = @(0, 0, 128);
        "DarkCyan" = @(0, 128, 128);
        "DarkGreen" = @(0, 128, 0);
        "DarkMagenta" = @(128, 0, 128);
        "DarkRed" = @(128, 0, 0);
        "DarkYellow" = @(128, 128, 0);
        "DarkGray" = @(128, 128, 128);
        "LightGray" = @(192, 192, 192);
    }

    static [string] getColortbl() {
        return [RtfUtil]::table.Values | ForEach-Object {
            $rgb = $_ -as [array]
            return ("\red{0}\green{1}\blue{2};" -f $rgb)
        } | Join-String -Separator "" -OutputPrefix "{\colortbl;" -OutputSuffix "}"
    }

    static [int] getColorIndex([string]$colorName) {
        $names = [RtfUtil]::table.Keys
        if ($colorName -in $names) {
            return $names.IndexOf($colorName) + 1
        }
        return $names.IndexOf("Yellow") + 1

    }

    static [string] escape([string]$s) {
        return $s.GetEnumerator() | ForEach-Object {
            $c = $_ -as [char]
            return  "\u{0}?" -f [System.Convert]::ToInt32($c)
        } | Join-String -Separator ""
    }

}

String型にメソッドを生やす

Update-TypeData で自作メソッドを生やします。

Update-TypeData -TypeName "System.String" -Force -MemberType ScriptMethod -MemberName "ToRtfHighlight" -Value {
    param([string]$color = "Yellow", [bool]$italic = $true, [bool]$bold = $false)
    $colortbl = [rtfURtfUtil]::getColortbl()
    $rtf = "\cf1"
    $rtf += "\highlight{0}" -f [RtfUtil]::getColorIndex($color)
    if ($italic) {
        $rtf += "\i"
    }
    if ($bold) {
        $rtf += "\b"
    }
    $rtf += " "
    $t = "{" + $rtf + [RtfUtil]::escape($this) + "}"
    return -join @("{", $colortbl, $t, "}")
}

用法:

> "あいう".ToRtfHighlight()

{{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;}{\cf1\highlight7\i \u12354?\u12356?\u12358?}}

RTFフォーマットでコピーするコマンドの定義

function Set-ClipboardAsRtf {
    <#
    .EXAMPLE
        "aa" + "bb".ToRtfHighlight() + "cc" | Set-ClipboardAsRtf
    #>
    param (
        [parameter(ValueFromPipeline = $true)][string]$inputLine
    )
    begin {
        $lines = @()
    }
    process {
        $lines += ("{" + $inputLine + "}")
    }
    end {
        $rtf = $lines | Join-String -Separator "\par" -OutputPrefix "{\rtf\fs21" -OutputSuffix "}"
        [System.Windows.Forms.Clipboard]::SetText($rtf, [System.Windows.Forms.TextDataFormat]::Rtf)
    }
}

RTFではブレースの対応さえ一貫していればネストは影響しないようです({{{{{あ}}}}} のようになっても普通に貼り付けられる)。
機械的にパイプ入力をブレースで囲んでみました。

メイン処理

Regex.Matches() で正規表現マッチさせると IndexLength を取得できます。
単純にループで文字列結合しています。

function ConvertTo-RtfInsideSymbol {
    param (
        [parameter(ValueFromPipeline = $true)][string]$inputLine
        ,[string]$symbol = "▲"
    )
    begin {
        $reg = [regex]::new("{0}.+?{0}" -f $symbol)
    }
    process {
        $s = ""
        $offset = 0
        foreach($m in @($reg.Matches($inputLine))) {
            $pre = $inputLine.Substring($offset, $m.Index - $offset)
            $s += [RtfUtil]::escape($pre)
            $deco = $m.Value.Trim($symbol).ToRtfHighlight("Yellow", $true, $false)
            $s += $deco
            $offset = $m.Index + $m.Length
        }
        if ($offset -lt $inputLine.Length) {
            $rest = $inputLine.Substring($offset)
            $s += [RtfUtil]::escape($rest)
        }
        $s | Write-Output
    }
    end {
    }
}

Discussion