5.4 画面キャプチャツール

(1/1)
通知領域のアイコンを右クリックし、画面キャプチャツールを操作する陽奈
「5.3 ファイル名一括変更ツール」で作ったツールは、起動して操作し、終わったら閉じる、という使い方だった。本項で作る画面キャプチャツール(プログラム名 pahooSnap)は、これとは性質が違う。常に裏側で動き続け(メモリに常駐する)、Windowsのどの画面を見ていても決めておいたキーを押した瞬間に反応するようWindows全体を相手にするプログラムである。
作った動機は単純である。Windows標準のスニップ機能に不満があったからだ。とくにWebP形式で保存できないことが不満だった。WebP はPNGより小さく、JPEGより高画質を保ちやすい画像形式で、ブログやWebページに載せる画面写真には向いている。無いのであれば作ればよい、というのが出発点である。
Windowsが標準で WebP をサポートしていない理由は「コラム:なぜWebP形式はWindows標準ではないのか」に記したが、プログラムの業務利用やオープンソースとしての配布を想定しているなら、仕様作成の段階で使用する機能・モジュールのライセンスについて調査をしておこう。

本項では、通知領域(タスクトレイ)への常駐、どのアプリを使っていても効くグローバルホットキー、Windows API(Win32)の呼び出し、画面の取り込み、マウスでの範囲・ウィンドウ指定、外部ライブラリによるWebP変換、クリップボードへの画像コピー、二重起動の防止、予期しないエラーへの対応などを解説する。これまでの題材はいずれもウィンドウの中で完結していたが、今回はじめて、ウィンドウの外側、Windows全体を相手にするプログラムを作る。

目次

作るもの ― 画面キャプチャツール

通知領域のアイコンを右クリックしたときのメニュー。保存先フォルダーを開く、出力先の選択、ホットキーの有効・無効、4種類のキャプチャ、詳細設定、ヘルプ、バージョン情報、技術情報サイト、終了の各項目が並ぶ
通知領域アイコンの右クリックメニュー
pahooSnapは、通知領域に常駐し、右クリックメニューまたはキーボードのホットキーから、次の4種類のスナップショットを撮るツールである。
  1. デスクトップ全体‥‥全モニターをまとめて1枚に保存する。
  2. トップレベルウィンドウ‥‥そのとき最前面にあるウィンドウだけを保存する。
  3. ウィンドウ‥‥マウスでクリックしたウィンドウを保存する。
  4. 指定範囲‥‥マウスでドラッグした矩形部分を保存する。
撮った画像は、画像ファイルへの保存とクリップボードへのコピーのどちらか一方、または両方へ出力できる。保存形式はBMP、JPEG、PNG、WebPの4種類から選べる。

Windows標準のSnipping Tool(切り取り&スケッチ)と比べたときの違いを下表にまとめる。
技術的にはクラウド連携を行う機能も搭載できるが、利便性とセキュリティ・リスクを天秤に掛けて不要と判断した。必要な方は、後述の仕様書に追記してプログラムを作ってみてほしい。クラウドサービス認証に使うパスワード等の保管は、後述の「5.9 パスワード保管ツール」を参考にしていただきたい。
Windows標準機能とpahooSnapの違い
比較点Windows標準pahooSnap
保存形式BMP・JPEG・PNG・GIFBMP・JPEG・PNG・WebP
出力先クリップボードまたはファイル、都度選択ファイル・クリップボードを個別にON/OFFし、常にその設定で動く
ホットキーPrintScreen系のみ、変更不可4機能それぞれに好きなキーを割り当て可能
保存先とファイル名固定(スクリーンショットフォルダーに連番)フォルダーと命名規則を指定可能
外部との通信クラウド連携あり一切行わない
次に、本ツールの機能を仕様として表に整理する。この表が、次の項で書く仕様書の骨組みになる。
画面キャプチャツールの機能
区分機能内容
起動常駐起動直後から通知領域に常駐し、ウィンドウは表示しない
呼び出し右クリックメニュー通知領域アイコンを右クリックすると機能を一覧表示する
呼び出しグローバルホットキー4機能それぞれにキーを割り当て、他のアプリを使っていても効く
対象デスクトップ全体/トップレベルウィンドウ/ウィンドウ/指定範囲4種類の取り込み範囲を選べる
出力画像ファイルに保存/クリップボードにコピー個別にON/OFFでき、両方選べば1回の操作で両方へ出す。少なくとも一方は必須
形式BMP・JPEG・PNG・WebPWebPはSkiaSharpライブラリでエンコードする
設定ホットキー・形式・保存先・ファイル名詳細設定画面から変更し、次回起動後も引き継ぐ
その他ヘルプ・バージョン情報取扱説明書をブラウザーで開く。版と使用条件を表示する

まず仕様書を書く

「5.3 ファイル名一括変更ツール」に続き、本項でも Codex:blue] にプログラム作成を依頼する前に仕様書を書く。今回はさらに、常駐、Windows全体に効くホットキー、複数の画像形式という、これまでより広い範囲の取り決めが必要になる。
決めごとが多いほど、仕様書に書き漏らしたことが後から食い違いを生みやすく、バイブコーディングを行っているとデグレーション(変更や更新を行った結果、それまで正常だった機能や性能が悪化する)の遠因になりやすい。仕様書を1つのファイルにまとめておくことで、Codexが常に矛盾をチェックすることで、デグレーションを起きにくくする。
「5.3 ファイル名一括変更ツール」のRenameKitは評価用の0.1.0から始めたが、本ツールは最初から人に配る前提で書いたので、作業区分を新規作成(配布用)とし、バージョンは1.0.0から始めている。仕様書には、この作業区分をユーザー(依頼する人間)が最初に決めて書いておく、という約束にしてある。作業の途中で機能を追加したときは0.1.0のように小数第1位を、不具合を直したときは小数第2位を1増やす。
仕様書(pahooSnap.md)に書く主な項目
項目書く内容本ツールの場合
目的何のための道具かデスクトップ画面を画像ファイルまたはクリップボードへ出力する
動作環境OS、実行に必要なものWindows 10以上の64ビット版、.NET 10 Desktop Runtime
技術要件使う技術、使わない技術C#、WPF。外部DLLは可能な限り使わない。外部と通信しない。待機中はポーリングせずイベント駆動とする
呼び出し方メニューとホットキーの構成標準Windows形式の右クリックメニュー、4機能ぶんのグローバルホットキー
出力形式対応する画像形式BMP、JPEG、PNG、WebP(WebPはSkiaSharpを使用)
例外・エラー処理異常時にどう振る舞うか処理エラーは画面に表示する。未処理の例外は詳細を表示して安全に終了する
データ保存何を、どこに覚えるかホットキー・形式・保存先・ファイル名を利用者ごとの領域にJSONで保存する
テスト方針どう確かめるかネットワークに接続しないダミーデータ試験を3回行う
バージョン規則番号の付け方新規作成(配布用)は1.0.0から開始する
配布の手順実行ファイルとMSIの作り方dotnet publish の後、WiX 4で64ビットMSIを作る
著作権・使用条件ライセンスと問い合わせ先MIT License

Codexへの依頼文

Codexへ渡す仕様書は、いつも通り、マークダウン形式にする。「5.3 ファイル名一括変更ツール」の依頼文のように「# 依頼」「# 画面」といった見出しで書く形も、「条件を1行ずつ列挙する」形も、どちらもCodexは正しく読み取る。書きやすいほうを選べばよい。本ツールでは、決めるべきことが多かったので、「1条件=1行」で見通しをよくした。
プロンプト:画面キャプチャツールを作る
# pahooSnap 開発記録

## 作成条件
- 新規作成(配布用)、バージョン 1.0.0 - Windows 10以上、64ビット、C# / WPF / .NET 10 - 通知領域へ常駐し、右クリックメニューとグローバルホットキーから起動 - 右クリックメニューは標準Windows形式とし、機能アイコン、区切り線、アクセスキーを表示 - メニューから保存先フォルダーの表示とホットキーの一時的な有効/無効切替が可能 - メニューの「画像ファイルに保存」「クリップボードにコピー」を個別に選択可能とし、少なくとも一方を必須とする - PrintScreenを含む特殊キーを単独またはキーコンビネーションでホットキー登録可能 - デスクトップ全体、トップレベルウィンドウ、指定ウィンドウ、指定範囲を保存 - BMP、JPEG、PNG、WebP画像に対応 - 保存先、ファイル名、ホットキーを永続化 - 外部通信およびサーバー技術は使用しない - ローカルHTMLヘルプ、バージョン情報、技術情報サイトへのリンクを用意 - 未処理例外は詳細表示後に安全に終了 - .NET 10 Release / win-x64、警告・エラー0件、自己テスト3件を合格条件とする - WiX 4による64ビットMSIを作成する
## 補足
WebPエンコードにはMIT LicenseのSkiaSharpを使用し、他形式では読み込まない。 ファイル名初期値は psnap-YYYYMMDD_hhmmss とする。 待機中はタイマーやポーリングを使用せず、Windowsメッセージによるイベント駆動とする。 アイコン原稿は resourceICON/pahooSnap.png、生成ICOは pahooSnap.ico とする。
通知領域へ常駐、グローバルホットキー、複数の画像形式、待機中はイベント駆動という4点が、これまでのツールに無かった新しい要求である。この4点さえ明確に書いておけば、Codexは適切なWindows APIを自分で選んで実装する。特別な事情がない限り、人間がAPI名まで指定する必要はない(.NETのAPIは大量にあるので、とてもではないが記憶できない)。
この依頼を受け、Codexは次のファイルを自動生成する。
Codexが作った主なファイルと役割
ファイル名種類役割
pahooSnap.csproj設定使う.NETの版、アプリ名、バージョン、アイコン、SkiaSharpの参照などプロジェクト全体の設定
App.xaml / App.xaml.csXAML / C#アプリの起動と終了。二重起動の防止、未処理例外の受け止め、自己テストの入口
MainWindow.xaml / .xaml.csXAML / C#通知領域アイコンとメニュー、詳細設定画面、ホットキーの登録と受信
NativeMethods.csC#Win32 APIの呼び出し宣言と、ウィンドウの位置・大きさを求める処理
CaptureService.csC#画面の取り込みと、指定した形式での画像保存
SelectionOverlay.xaml / .xaml.csXAML / C#マウスでウィンドウや範囲を指定するための全画面オーバーレイ
Settings.csC#設定をJSONファイルへ保存し、読み出す
SelfTests.csC#自分自身の動作を確かめる試験
画面を映す処理(CaptureService.cs)とWindowsとやり取りする処理(NativeMethods.cs)が、画面の見た目(MainWindow.xaml.cs)から分かれている点は、「5.3 ファイル名一括変更ツール」のRenameEngine.csと同じ考え方である。こう分けておくと、通知領域や画面が無くても、キャプチャと保存の処理だけを自己テストで試せる。

完成したツールのダウンロード

ダウンロードしたZIPファイルには、ツールの仕様書 pahooSnap.md やインストーラー、取扱説明書、Visual Studioで手動でコンパイル・ビルドするときに必要になるリソース群などが格納されている。

通知領域に常駐する ― NotifyIcon

これまでのWPFアプリは、ウィンドウを閉じれば終了した。pahooSnapは逆で、ウィンドウを閉じても終了しない。詳細設定画面の「キャンセル」やタイトルバーの×は、ウィンドウを隠すだけで、常駐は続く。この仕組みは、ウィンドウのClosingイベントをつかまえて実現する。
MainWindow.xaml.cs(常駐を続けるための仕掛け・抜粋)
public MainWindow()
{
    _settings = SnapSettings.Load();
    _handle = new WindowInteropHelper(this).EnsureHandle();
    _source = HwndSource.FromHwnd(_handle);
    _source.AddHook(WndProc);
    Closing += (_, e) =>
    {
        if (!_allowClose) { e.Cancel = true; Hide(); }
    };
}
`e.Cancel = true` でウィンドウを閉じる動作そのものを取り消し、代わりに `Hide()` で見えなくするだけにする。本当にアプリを終わらせたいときは、メニューの「終了」から '_allowClose` を true にしてから閉じる。この1個のフラグが、利用者が意図して終了する場合と、誤ってウィンドウを閉じてしまった場合を区別している。
WPFには、System.Windows.Forms.NotifyIcon(.NET Framework時代からある通知領域アイコンの部品)をそのまま使う。WPFにこの機能は無いが、.NETでは WPFとWindows Forms(WinForms)を1つのプロジェクトで併用できる。csprojに `<UseWindowsForms>true</UseWindowsForms>` の1行を加えるだけでよい。
MainWindow.xaml.cs(通知領域アイコンの用意・抜粋)
public void InitializeTray()
{
    _tray = new Forms.NotifyIcon
    {
        Icon = new Icon(iconPath),
        Text = "pahooSnap - 画面スナップショットツール",
        Visible = true,
        ContextMenuStrip = BuildTrayMenu()
    };
    _tray.DoubleClick += (_, _) => ShowSettings();
}
右クリックメニューの中身はContextMenuStrip(これもWindows Formsの部品)で組み立てる。メニュー項目にはアクセスキー(項目名の中の & の直後の文字に下線が付き、Altキーと組み合わせて選べる)や、チェック付きの項目(画像ファイルに保存/クリップボードにコピー、ホットキーの有効・無効)を用意でき、通知領域メニューに求められる見た目をそのまま再現できる。
詳細設定の画面は、アプリ起動時ではなく、最初に必要になったときに組み立てる。
MainWindow.xaml.cs(詳細設定の画面)
private void EnsureSettingsUi()
{
    if (_settingsUiInitialized) return;
    InitializeComponent();
    _settingsUiInitialized = true;
}
常駐アプリは長時間立ち上がったままになるため、使わない画面をあらかじめ作っておくとメモリを無駄にする。実際、pahooSnapの待機中の使用メモリは82.6MB、CPU使用率は計測の限り0.000%であり、こうした省リソースの工夫の積み重ねによる。

グローバルホットキーを登録する

「3.5 ToDoリスト」で扱ったキーボード操作は、そのページを見ている間だけ効くものだった。pahooSnap のホットキーは、他のアプリを操作していても効かなければならない。この違いを実現するのが、Win32のRegisterHotKeyという関数である。
キーボードを押すと、Windowsからアプリへメッセージが届き、キャプチャが実行される流れを示す図解
仕組みは2段構えになっている。まず起動時に、このキーの組み合わせが押されたら教えてほしいとWindowsへ登録しておく(RegisterHotKey)。登録したキーが押されると、WindowsはそのウィンドウへWM_HOTKEY というメッセージを送る。ボタンをクリックしたときの Clickイベントと似ているが、ウィンドウが表に出ていなくても届く点が違う。
MainWindow.xaml.cs(ホットキーの登録とメッセージ受信・抜粋)
private void RegisterHotkeys(SnapSettings settings)
{
    string[] values = [settings.DesktopHotkey, settings.ActiveHotkey,
                        settings.WindowHotkey, settings.RegionHotkey];
    for (int i = 0; i < values.Length; i++)
    {
        var (modifiers, key) = ParseHotkey(values[i]);
        NativeMethods.RegisterHotKey(_handle, i + 1, modifiers, key);
    }
}

private nint WndProc(nint hwnd, int message, nint wParam, nint lParam, ref bool handled) { if (message != NativeMethods.WmHotkey) return 0; handled = true; _ = ((int)wParam) switch { 1 => CaptureDesktopAsync(), 2 => CaptureActiveWindowAsync(), 3 => CaptureSelectedAsync(SelectionMode.Window), 4 => CaptureSelectedAsync(SelectionMode.Region), _ => Task.CompletedTask }; return 0; }
登録のとき渡す番号(1~4)が、後でどの機能が呼ばれたかを見分けるための目印になる。 WndProc は、このウィンドウに届いたWindowsメッセージをすべて覗き見る関数で、WPFでは `HwndSource.AddHook` でこれを差し込む。WM_HOTKEY以外のメッセージは[素通りさせ、自分に関係のあるものだけ処理するのが作法である。
ホットキーの指定は、修飾キー(Ctrl・Alt・Shift・Win)と通常キーを組み合わせた数値で表す。既定値は下表のとおりである。
既定のホットキー
機能既定ホットキー
デスクトップ全体Ctrl+Shift+F9
トップレベルウィンドウCtrl+Shift+F10
ウィンドウCtrl+Shift+F11
指定範囲Ctrl+Shift+F12
仕様書には PrintScreenを含む特殊キーに対応すると書いた。
ここでちょっとした落とし穴がある。WPFの世界では、PrintScreenキーは押した瞬間のKeyDownイベントが発生せず、離した瞬間のKeyUpイベントしか発生しない。したがって、他のキーと同じ書き方でホットキー欄に登録しようとすると、PrintScreenだけ反応しないことになる。
これは、実際に動かしてみたいと気付かない類いの仕様バグである(個人的には WPF のしようが具だと感じている)。
そこで、次のメソッドを加えることにした。
MainWindow.xaml.cs(PrintScreenだけKeyUpで拾う・抜粋)
private void HotkeyBox_PreviewKeyUp(object sender, KeyEventArgs e)
{
    Key key = e.Key == Key.System ? e.SystemKey : e.Key;
    if (key != Key.Snapshot) return;
    e.Handled = true;
    SetCapturedHotkey(sender, key);
}
Key.Snapshot がPrintScreenキーを表す。自己テストにも `PrintScreenキーの変換結果が不正です。` という確認を加え、直したことをもう一度壊さないようにしてある(「自己テストで動作を確かめる」)。ScrollLock、Pause、Insert/Delete、PageUp/PageDown、矢印キー、音量・メディアキーなどの特殊キーにも、同様に名前を対応づける辞書を用意して読みやすい表示にしている。

Win32 APIをC#から呼び出す ― LibraryImport

RegisterHotKeyや、後で使うウィンドウの位置取得は、.NETに用意された機能ではなく、Windows自身が持つ関数(Win32 API)である。C#からこうした古い関数を呼び出す仕組みを P/Invoke(Platform Invoke)と呼ぶ。pahooSnapでは、その中でも比較的新しい書き方である LibraryImport という目印(属性)を使っている。
NativeMethods.cs(Win32 API宣言・抜粋)
internal static partial class NativeMethods
{
    [LibraryImport("user32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool RegisterHotKey(nint hWnd, int id, uint fsModifiers, uint vk);
    
[LibraryImport("user32.dll")] internal static partial nint GetForegroundWindow();
[LibraryImport("dwmapi.dll")] internal static partial int DwmGetWindowAttribute(nint hwnd, int attribute, out Rect rect, int size); }
以前からある書き方は `[DllImport("user32.dll")]` という属性だった。LibraryImportはこれの後継で、.NETのビルド時に橋渡しのコードを自動生成する。実行時に毎回組み立てる古い方式より速く、書き方も似ているため、新しくWindows APIを呼ぶコードは今後こちらが主流になる(ソース生成による P/Invoke)。クラスとメソッドにpartial(部分定義)を付けるのが決まりで、残りの半分はビルド時にコンパイラが書き足す。

一覧の最後にあるDwmGetWindowAttributeは、GetWindowRectだけでは正確に取れない値を補うために使う。ふつうのウィンドウの矩形(GetWindowRect)には、Windows 10以降で追加された見えない影の余白が含まれてしまうことがある。DWM(Desktop Window Manager、画面の合成を担うWindowsの機能)に実際に見えている範囲を尋ねるのがDwmGetWindowAttributeで、これが失敗したときだけGetWindowRectへ切り替える、という2段構えにしてある。
NativeMethods.cs(ウィンドウの見えない影の余白を省く)
internal static bool TryGetWindowBounds(nint hwnd, out Rectangle rectangle)
{
    if (DwmGetWindowAttribute(hwnd, DwmwaExtendedFrameBounds, out Rect dwmRect, ...) == 0)
        rectangle = dwmRect.ToRectangle();
    else if (GetWindowRect(hwnd, out Rect rect))
        rectangle = rect.ToRectangle();
    return rectangle.Width > 0 && rectangle.Height > 0;
}
こうしたWindowsの流儀に合わせた細かな調整は、最初から仕様書に書く必要はない。「ウィンドウを保存したら影の分だけ余白が写り込む」という結果を見て初めて気づき、Codexへ「余白が写り込む」とだけ伝えれば、この対処を加えてくれる。

画面をキャプチャする ― Graphics.CopyFromScreen

デスクトップ全体も、ウィンドウも、指定範囲も、pahooSnapの中では同じ1つの処理で撮っている。違うのは、どの矩形(Rectangle)を渡すかだけである。矩形さえ決まれば、その部分を画面から切り出す処理は共通でよい。
CaptureService.cs(画面の取り込み・抜粋)
internal static Bitmap CaptureBitmap(Rectangle bounds)
{
    var bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
    using (Graphics graphics = Graphics.FromImage(bitmap))
        graphics.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size,
                                 CopyPixelOperation.SourceCopy);
    return bitmap;
}
Graphics.CopyFromScreenが、画面という「今見えているもの」を、プログラムが扱えるビットマップという入れ物に写し取る。それぞれの対象で渡す矩形は次のように決める。
  1. デスクトップ全体‥‥ `Forms.SystemInformation.VirtualScreen` 。複数のモニターをまたいだ全体の範囲を返してくれるので、マルチモニター環境でも1回の呼び出しで済む。
  2. トップレベルウィンドウ‥‥「Win32 APIをC#から呼び出す」のTryGetWindowBoundsで求めた、最前面のアプリの矩形。
  3. ウィンドウまたは指定範囲‥‥次の「マウスでウィンドウや範囲を指定する」でマウス操作から求めた矩形。
矩形さえ渡せば同じ関数で撮れる、という設計にしておくことで、対象の見分け方と撮る処理がきれいに分かれ、どちらかに手を入れてももう一方へ影響しない。

マウスでウィンドウや範囲を指定する

「ウィンドウ」と「指定範囲」の2機能では、画面全体を覆う、うっすら透けた黒いウィンドウを一瞬だけ出し、その上でマウス操作を受け取る。これをオーバーレイと呼ぶ。
SelectionOverlay.xaml(全画面の透けたウィンドウ)
<Window x:Class="PahooSnap.SelectionOverlay"
        WindowStyle="None" AllowsTransparency="True" Background="#55000000"
        Topmost="True" ShowInTaskbar="False" Cursor="Cross" ResizeMode="NoResize">
  <Canvas x:Name="Surface">
    <Rectangle x:Name="Outline" Stroke="#FF24A0ED" StrokeThickness="3"
               Fill="#22FFFFFF" Visibility="Collapsed"/>
    <Border Canvas.Left="16" Canvas.Top="16" Background="#DD202020" CornerRadius="5">
      <TextBlock x:Name="GuideText" Foreground="White"/>
    </Border>
  </Canvas>
</Window>
WindowStyle="None" で枠や題名を消し、AllowsTransparency="True" と半透明の背景色で下の画面を透かして見せ、Topmost="True" で常に最前面にする。この3つを組み合わせると、普通のボタンやウィンドウとは違う、画面全体を覆う特別な入れ物ができる。左上の案内文字(GuideText)で「ドラッグして範囲を指定してください(ESCで中止)」のように操作方法を示す。

指定範囲モードでは、マウスを押した位置と、動かした先の位置から矩形を計算し、Outline という水色の枠をSetOutlineで伸び縮みさせる。
ウィンドウモードでは、マウスの位置にあるウィンドウを常に探し続け、見つかったウィンドウの輪郭を同じ枠で示す。ウィンドウを探す処理は、Win32のGetWindow関数で、重なっているウィンドウを手前から奥へ1つずつたどる、という素朴な方法である。
SelectionOverlay.xaml.cs(ウィンドウを探す処理・抜粋)
private bool TryWindowAt(Point point, out Rectangle bounds)
{
    nint hwnd = NativeMethods.GetWindow(_overlayHandle, NativeMethods.GwHwndNext);
    for (int count = 0; hwnd != 0 && count < 512; count++,
        hwnd = NativeMethods.GetWindow(hwnd, NativeMethods.GwHwndNext))
    {
        if (NativeMethods.TryGetWindowBounds(hwnd, out bounds)
            && bounds.Contains(nativePoint)) return true;
    }
    bounds = default;
    return false;
}
Escキーを押すと `DialogResult = false` にしてオーバーレイを閉じ、キャプチャを行わずに終える。オーバーレイは ShowDialog で開くため、呼び出し側は「選ばれた矩形」または「取り消された」という結果を、閉じた瞬間に受け取れる。この開いて、結果を受け取って、閉じるという形は、詳細設定画面のような通常の画面とは違う、一度限りの入力を受け取るための部品の作り方である。

WebP形式で保存する ― SkiaSharpの利用

作成の動機であった WebP対応を実現する部分である。
.NETの標準機能(System.Drawing)は、BMP・JPEG・PNG・GIF・TIFFは書き出せるが、WebPは書き出せない。そこで、Googleが公開している2次元グラフィック・ライブラリ「SkiaSharp」を1つだけ追加する。その導入方法を説明する。
pahooSnap.csproj(NuGetパッケージの参照・抜粋)
<ItemGroup>
  <PackageReference Include="SkiaSharp" Version="4.152.1" />
</ItemGroup>
この1行をcsprojに加えるだけで、次にビルドしたときに自動でダウンロードされ、使えるようになる。
SkiaSharp は MIT License(このアプリ自身と同じ使用条件)で配布されている画像処理ライブラリで、Google製の描画エンジンSkiaをC#から使えるようにしたものである。仕様書にも「他形式では読み込まない」、つまりWebP専用に絞って使うと明記し、依存を最小限にとどめている。
CaptureService.cs(WebPへの変換・抜粋)
private static void SaveWebP(Bitmap bitmap, string path)
{
    BitmapData data = bitmap.LockBits(rectangle, ImageLockMode.ReadOnly,
                                       PixelFormat.Format32bppPArgb);
    var info = new SKImageInfo(bitmap.Width, bitmap.Height,
                                SKColorType.Bgra8888, SKAlphaType.Premul);
    using var pixmap = new SKPixmap(info, data.Scan0, data.Stride);
    using var image = SKImage.FromPixels(pixmap);
    using SKData encoded = image.Encode(SKEncodedImageFormat.Webp, 90);
    using FileStream stream = File.Create(path);
    encoded.SaveTo(stream);
}
System.Drawing側のBitmapと SkiaSharp側のSKImage は別々のライブラリが持つ別々の型なので、そのまま渡し合うことはできない。[LockBits:blue を使って Bitmapの生の画素データを取り出し、SKPixmap という形で SkiaSharp 側に橋渡ししてから、Encode(SKEncodedImageFormat.Webp, 90)で WebPへ変換する。最後の 90 は画質(0~100)の指定で、JPEGの品質指定と同じ考え方である。
自己テストでは、できあがったファイルの中身が本当にWebPかどうかまで確かめている。
SelfTests.cs(WebPシグネチャの確認・抜粋)
byte[] header = File.ReadAllBytes(path);
if (Encoding.ASCII.GetString(header, 0, 4) != "RIFF"
    || Encoding.ASCII.GetString(header, 8, 4) != "WEBP")
    throw new InvalidOperationException("WebPシグネチャが不正です。");
拡張子が `.webp` になっていても、中身が壊れていては意味が無い。ファイルの先頭8バイトに書かれた合図(シグネチャ)を読み、正しい形式で書けているかまで機械的に確認している。

クリップボードへ画像をコピーする

「3.6 重複データ検出・並べ替え」で使ったWebブラウザーのClipboard APIは文字列を対象にしていたが、pahooSnap が扱うのは画像である。デスクトップアプリでは、System.Windows.Forms.Clipboard.SetImageでビットマップをそのままクリップボードへ渡せる。
キャプチャした画像が、画像ファイルへの保存とクリップボードへのコピーの2方向に分かれる図解。少なくとも一方が選ばれていることを示す
出力は「ファイル」「クリップボード」のどちらか、または両方
クリップボードは他のアプリと共有する資源である。ちょうど別のアプリがクリップボードを開いている瞬間に書き込もうとすると、まれに失敗する。pahooSnapは、これに備えて3回まで、少し間を置いて再挑戦するという単純な対策を入れている。
MainWindow.xaml.cs(クリップボードへのコピー・抜粋)
private static async Task CopyToClipboardAsync(Bitmap bitmap)
{
    for (int attempt = 1; attempt <= 3; attempt++)
    {
        try { Forms.Clipboard.SetImage(bitmap); return; }
        catch (ExternalException) when (attempt < 3) { await Task.Delay(50); }
    }
}
出力先の選び方には、少なくとも一方を選ぶという制約を設けている。右クリックメニューで両方のチェックを外そうとすると、外れずに出力先を少なくとも1つ選択してくださいという吹き出し(バルーンヒント)が出る。
MainWindow.xaml.cs(出力先を必ず1つ以上残す・抜粋)
if (!fileItem.Checked && !clipboardItem.Checked)
{
    clickedItem.Checked = true;
    _tray.ShowBalloonTip(2000);
    return;
}
両方とも無しという状態を、そもそも作れないようにするのが要点である。「保存先が無い」というエラーを後から表示するより、最初から起こり得ないようにするほうが、利用者にとっても作り手にとっても分かりやすいからだ。

二重起動を防ぐ ― Mutex

常駐アプリを誤って2回起動すると、通知領域アイコンが2つ並んだり、同じホットキーの登録が競合したりして、利用者を混乱させる。pahooSnapは、起動直後にMutex(ミューテックス)という仕組みを使って、自分より先に起動している自分がいないかを確認する。
App.xaml.cs(二重起動の防止・抜粋)
_mutex = new Mutex(true, "Local\\pahoo.org.pahooSnap", out bool created);
if (!created)
{
    MessageBox.Show("pahooSnap は既に起動しています。");
    Shutdown();
    return;
}
Mutex は、本来は複数のスレッドが同じ資源を同時に触らないようにする排他制御のための仕組みだが、名前を付けて作ると、Windows全体で1つしか存在できない目印としても使える。2回目の起動では `reated` が false になるので、それを見てすでに動いていると判断し、案内を出してすぐに終了する。名前に pahoo.org と pahooSnap の両方を含めているのは、他の会社やアプリが同じ名前を使って競合しないようにするためである。

予期しないエラーで安全に終了する

「3.5 ToDoリスト」では、想定外のエラーを画面に表示して止めるという原則を扱った。デスクトップアプリでは、この受け止め方が2か所に分かれる。
App.xaml.cs(未処理例外の受け止め・抜粋)
DispatcherUnhandledException += (_, args) =>
{
    MessageBox.Show($"予期しないエラーが発生しました。安全のため終了します。\n\n{args.Exception}");
    args.Handled = true;
    Shutdown(1);
};
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
    File.WriteAllText(Path.Combine(Path.GetTempPath(), "pahooSnap-error.txt"),
                       args.ExceptionObject.ToString());
};
  1. DispatcherUnhandledException‥‥画面を動かしているスレッド(UIスレッド)で起きた例外を受け止める。ボタンを押したときの処理でエラーが起きた場合などが該当する。メッセージを表示し、`args.Handled = true` でWindows標準のクラッシュ表示を止めてから、安全に終了する。
  2. AppDomain.CurrentDomain.UnhandledException‥‥それ以外の場所(別スレッドなど)で起きた、もう手の施しようがない例外を受け止める。この段階では画面を出すことさえ安全ではない場合があるため、メッセージ箱は出さず、内容をテキストファイルへ書き残すだけにとどめている。
両方とも、原因を直す処理ではない。利用者に異常を伝え、原因の手がかりを残し、暴走させずに終わらせるための最後の砦である。原因そのものは、次の「[#Deb ugging:title=うまく動かないときの調べ方]」で扱うログを見て、Codexと一緒に調べる。

うまく動かないときの調べ方

「5.3 ファイル名一括変更ツール」で決めたとおり、本講座では Visual Studioの画面を開かず、ログで調べるという方針を貫く。pahooSnap の最初の依頼文にはログ出力を含めなかったので、必要になった時点で追加を依頼する。
プロンプト:ログのファイル出力を追加する
# 依頼
pahooSnap に、動作の記録(ログ)をファイルへ書き出す機能を追加してほしい。

# 保存先 - %LOCALAPPDATA%\pahoo.org\pahooSnap\log\ に保存する。 - ファイル名は pahooSnap_yyyyMMdd.log とし、日付ごとに分ける。 - 30日より古いログファイルは、起動時に自動で削除する。
# 記録する内容 - 1行につき「日時 種別 内容」の順に、タブ区切りで書く。 - 種別は INFO(通常)、WARN(注意)、ERROR(異常)の3種類とする。 - 次のできごとを記録する。 - アプリの起動と終了(バージョン番号も記録する) - ホットキーの登録の成功・失敗(失敗した場合はキーの内容) - キャプチャの実行(対象の種類、保存先パス、クリップボードの成否) - 未処理例外(種別 ERROR で、内容と発生個所)
# 条件 - 外部ライブラリ(NuGetパッケージ)は追加しない。 - ログの書き込みに失敗しても、アプリの動作は止めない。 - 追加後、dotnet build でビルドし、警告とエラーが無いことを確認してほしい。
ログを追加したあとの調べ方は、5.3で示した手順と同じである。時刻の新しいものから見て、ERROR と書かれた行を探し、その直前の行を見る。常駐アプリならではの手がかりも1つ増える。「ホットキーが効かない」という症状のときは、登録の失敗がログに残っていないかをまず確認する。他のアプリがすでに同じキーを使っていると、RegisterHotKeyそのものが失敗するからである。
不具合の伝え方と、Codexが行うこと
人間が伝えることできれば添えるとよい情報Codexが行うこと
ホットキーを押しても反応しないログにホットキー登録失敗の行があるか他のアプリと重複していないか調べ、利用者に別のキーを促す処理を検討する
通知領域にアイコンが出ないログの起動直後の数行アイコンファイルの読み込みや初期化の処理を確認する
WebPで保存すると壊れたファイルになる保存直後のファイルサイズSkiaSharpのエンコード処理とファイルの書き込みを確認する
クリップボードにコピーされないログのERROR行他のアプリのクリップボード占有を疑い、再試行の回数や待ち時間を見直す
2回目に起動しても何も起きない1回目を終了させたか二重起動防止の仕組みが正しく動いているかを確認する(意図した動作である旨を伝える)

自己テストで動作を確かめる

常駐アプリは、画面を出さずに動作を確かめられると都合がよい。pahooSnapも `--self-test` を付けて起動すると、通知領域アイコンを出さずに3つの試験を行い、結果を表示して終了する。
pahooSnap.exe --self-test
実行すると、次のように表示される(TEST_RESULTS.txtにも保存される)。
pahooSnap 1.0.0 SELF-TEST
実行日時: 2026-09-19 20:47:47
ネットワーク接続: なし
[PASS] 設定データの往復変換
[PASS] ファイル名テンプレート展開
[PASS] 画像形式エンコード
結果: 3/3 成功 - 合格
自己テストの3項目
名前確かめること合格の条件
設定データの往復変換設定をJSONへ変換し、また読み戻すこと保存した値がすべて読み戻せる。出力先が両方未選択なら不正と判定する
ファイル名テンプレート展開YYYYMMDD_hhmmss等の置き換えと、特殊キー名の変換決まった日時から決まったファイル名になる。PrintScreenキーが正しく変換される
画像形式エンコードBMP・JPEG・PNG・WebPすべての書き出しいずれの形式もファイルができ、WebPは先頭のRIFF/WEBPの合図が正しい
「5.3 ファイル名一括変更ツール」の自己テストは5項目だったが、本ツールは3項目にとどめてある。試験の数そのものに決まりは無く、そのツール特有の、間違えやすい急所を突く試験であれば数は問わない。3件目の画像形式エンコードは、4つの形式をまとめて1つの試験の中で確かめている点にも注目してほしい。似た処理をまとめて1つの試験で扱うか、1つずつに分けるかは、読みやすさと、失敗したときの原因の絞りやすさで判断すればよい。

dotnet publishで実行ファイルを作る

発行の考え方そのものは「5.3 ファイル名一括変更ツール」のdotnet publishで実行ファイルを作るで説明したとおりである。pahooSnapでも同じ形のコマンドを使う。
dotnet publish -c Release -r win-x64 --self-contained false -o publish
5.3のRenameKitとの違いは2点である。まず、UseWindowsFormsを使っているため、Windows専用のAPI呼び出しに対する警告(コードCA1416)が出る。csprojに `<NoWarn>CA1416</NoWarn>` を1行加え、Windows専用と分かった上で使っていることを明示して警告を抑える。もう1つは、発行フォルダーに SkiaSharp.dll と libSkiaSharp.dll という、NuGetで追加したライブラリの実体が加わる点である。これらもインストーラーに含め忘れないよう、次の項のWiX文書に列挙する。
pahooSnap.csproj(警告をエラーとして扱う・抜粋)
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>CA1416</NoWarn>
TreatWarningsAsErrors(警告をエラーとして扱う)を有効にしたまま、対応済みの警告だけを個別に許可する、というこの書き方は、知らない警告を見逃さず、分かっている警告だけ黙らせるための定石である。

WiX Toolset v4でMSIインストーラーを作る

WiX Toolset v4の基本は「5.3 ファイル名一括変更ツール」のWiX Toolset v4でMSIインストーラーを作るを参照してほしい。ここでは、pahooSnap 固有の点だけを示す。
installer.wxs(要点のみ抜粋)
<Package Name="pahooSnap画面スナップショットツール" Manufacturer="pahoo.org"
         Version="1.0.0" UpgradeCode="AEFBCCF4-8D39-4B55-A0B6-E1FC1AEE0A63"
         Language="1041" Codepage="932" Scope="perMachine">
  <Icon Id="AppIcon" SourceFile="pahooSnap.ico"/>
  <Property Id="ARPPRODUCTICON" Value="AppIcon"/>
  ...
  <Component Id="MainProgram" ...>
    <File Id="MainExe" Source="publish\pahooSnap.exe" KeyPath="yes"/>
    <File Id="SkiaSharpDll" Source="publish\SkiaSharp.dll"/>
    <File Id="SkiaNativeDll" Source="publish\libSkiaSharp.dll"/>
    ...
  </Component>
  <Directory Id="HelpFolder" Name="help">
    <Component Id="HelpFiles" ...>
      <File Id="HelpHtml" Source="help\index.html" KeyPath="yes"/>
      <File Id="HelpCss" Source="help\style.css"/>
    </Component>
  </Directory>
</Package>
Icon と ARPPRODUCTICON の2行は、Windowsの「インストールされているアプリ」の一覧にアイコンを表示するための指定である。忘れると、既定の白紙アイコンで表示されてしまう。HelpFolder の下に取扱説明書のHTMLとCSSを収めているのも要点である。プログラム本体だけでなく、実行時に読みに行くファイル一式をインストーラーへ含め忘れないことが肝心で、SkiaSharpのDLL2つも同じ理由でここに列挙してある。
本ツールではデスクトップショートカットのみを作る。スタートメニューへの登録は行わない。常駐して通知領域から使うツールという性質上、デスクトップか通知領域から呼び出せれば十分、という判断である。ビルドコマンドは5.3と同じ形である。
wix build installer.wxs -ext WixToolset.UI.wixext -culture ja-JP -arch x64 -o pahooSnap_1.0.0.msi

取扱説明書とヘルプメニュー

取扱説明書の仕組みは5.3と同じで、HTMLで書いた説明書をMSIに同梱し、通知領域メニューの「ヘルプ」から既定のブラウザーで開く。
MainWindow.xaml.cs(ヘルプの表示・抜粋)
private void OpenHelp()
{
    string path = Path.Combine(AppContext.BaseDirectory, "help", "index.html");
    Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
説明書には、「使い方」「出力方法」「設定」「終了」の4つの見出しを立て、既定ホットキーの一覧を表で載せてある。ウィンドウを閉じるだけでは常駐を続けるという、初めて使う人が誤解しやすい点は、「終了」の項目でとくにはっきり書いておく。
取扱説明書に載せる項目
項目内容
使い方通知領域アイコンの右クリックから4種類の対象を選ぶ操作
出力方法画像ファイルへの保存とクリップボードへのコピーの選び方
設定ホットキー・画像形式・保存フォルダー・ファイル名の変更方法
終了ウィンドウを閉じても常駐が続くこと、終了はメニューから行うこと
使用条件MIT LicenseとSkiaSharpの著作権表示
メニューの「バージョン情報」には、名称、版、著作権表示、使用条件を表示する。「技術情報サイト」からは本記事のページを開けるようにしてあり、疑問があれば元の解説へすぐ戻れるようにしてある。

よくあるつまずきと対処

本項の作業でつまずきやすい個所を下表にまとめる。「症状」の欄は、いずれも人間が見て分かることだけを書いてある。
画面キャプチャツールでよくあるつまずき
症状原因対処
起動しても通知領域に何も出ないアイコンファイルの読み込みに失敗している、または二重起動防止で即終了している「通知領域にアイコンが出ない」とCodexへ伝える。すでに起動していないか確認する
ホットキーを押しても反応しない他のアプリがすでに同じキーの組み合わせを使っている詳細設定で別のキーの組み合わせに変更する
PrintScreenキーだけ登録できないPrintScreenはKeyUpでしか拾えない(本文参照)「PrintScreenキーが登録できない」とCodexへ伝える
ウィンドウの端に余計な余白が写り込むGetWindowRectがドロップシャドウ分を含んでいる「ウィンドウの周りに余白が写る」とCodexへ伝える。DWMの拡張フレーム境界を使う対処がある
WebPを選んでも保存できないSkiaSharpのDLLが実行フォルダーに無いpublishフォルダーにSkiaSharp.dllとlibSkiaSharp.dllがあるか確認する
クリップボードにコピーされないことがある他のアプリがクリップボードを使用中である再試行の回数や待ち時間を増やすようCodexへ依頼する
2つ目のウィンドウやアイコンが出る二重起動防止が効いていない「2回目の起動で新しいアイコンが増える」とCodexへ伝える
publishしたexeが起動しない.NET 10 デスクトップランタイムが入っていない表示された案内に従って導入する
MSIの一覧でアイコンが白紙になるinstaller.wxsにIconとARPPRODUCTICONの指定が無い「アプリの一覧のアイコンが表示されない」とCodexへ伝える
本項では、常駐、グローバルホットキー、Win32 APIの呼び出し、画面の取り込み、マウスによる範囲・ウィンドウ指定、外部ライブラリによるWebP変換、クリップボードコピー、二重起動防止、未処理例外への対応までを1本のツールで通した。
パソコンの中で完結していたこれまでのツールと違い、常に裏側で動き続け、Windows全体を相手にするという新しい題材だった。

コラム:なぜWebP形式はWindows標準ではないのか

技術の優秀さを乗せた皿と、特許・ライセンスの条件を乗せた皿を持つ天秤が、ライセンス側にわずかに傾いている図解
ここまでの説明で、BMP・JPEG・PNGは.NET標準のSystem.Drawingでそのまま書き出せるのに、WebPだけはSkiaSharpという外部ライブラリが必要になるという違いに気づいた読者もいるだろう。この違いは、単に「WebPが新しい形式だから」では説明がつかない。ここでは本筋から少し離れ、画像形式の権利関係という切り口から考えてみることにする。
まず整理しておきたいのは、著作権があるかどうかと特許のライセンス料が発生するかどうかは別の問題だという点である。画像形式の仕様書や実装コードには当然著作権があるが、それ自体はWindows標準採用の大きな障害にはならない。障害になりやすいのは、圧縮技術を実装・配布すると特許使用料が発生する可能性があるかどうかのほうである。
おもな画像形式と権利関係の違い
形式著作権特許ロイヤリティフリー性Windows採用
を妨げる度合い
WebPあり関連特許あり高い小さい
AVIF
(AV1)
あり多数ありAOMediaがロイヤリティフリーのライセンスを整備小さい
HEIF
(コンテナ)
ありコンテナと中身のコーデックは別問題コーデック次第HEVCと組み合わせると大きい
HEIC
(HEIF+HEVC)
あり多数あり基本的に無償ではない大きい
JPEG XLあり関連特許ありロイヤリティフリーのライセンスあり小さい
WebPはGoogleが2010年に公開した形式で、非可逆圧縮の土台にはVP8の技術を使っている。実装である `libwebp` はオープンソースで公開されており(An image format for the Web:Google for Developers)、権利関係で身動きが取れなくなるような形式ではない。
それでも標準に組み込まれなかったのは、WebPが当初からWeb配信用の画像として急速に普及した一方、Windowsの写真管理やカメラ保存形式として使われる場面が少なかったからだと考えられる。
JPEGやPNGはデスクトップでもWebでも欠かせないが、WebPは主にブラウザやWebサーバーで重要な形式である。だからこそMicrosoftは、Windows本体に恒久的に組み込むのではなく、WebP Image Extensionという後から更新できる拡張機能の形で提供する道を選んだ(WIC codecs from Microsoft:Microsoft Learn)。

次に、WebP 以外の画像形式についても考察してみよう。

AVIFはAV1という動画コーデックを静止画に転用した形式である。
AV1を策定したAlliance for Open Media(AOMedia)は、必要な特許について「世界中で・無償で・ロイヤリティフリー」と明記したライセンス方針を掲げている(Alliance for Open Media Patent License 1.0:Alliance for Open Media)。Microsoft自身もAOMediaに参加しており、AV1は動画分野でも重要になっているため、デコーダーへの投資が動画・静止画の両方に生きるという利点がある。ただしWindows上のAVIFは独立した形式というより、HEIFコンテナにAV1の圧縮を収めたものとして扱われる(What is AVIF?:Alliance for Open Media)。

HEIF/HEICがもっとも権利関係が複雑である。
HEIFそのものは画像データをどう箱に収めるかを決めるコンテナ形式であり、圧縮方式そのものではない(HEIF extension codec:Microsoft Learn)。一般的な `.heic` ファイルは、このHEIFにHEVC(H.265)という圧縮方式を組み合わせたものだが、HEVCは多数の企業が特許を持つ技術の集合体で、ライセンス費用の問題が生じやすい。iPhoneの普及で対応せざるを得ない一方、HEVCのデコーダーはWindows標準には含まれず、Microsoft Storeから別途入手する形になっているのは、このためである。
また、今回、WebPと並んで圧縮率の高いHEIFを機能として加えなかったのは、HEVC(H.265)にライセンスがあり、本講座のポリシーである MIT License にできないという理由からである。

JPEG XLはISO/IEC 18181として標準化された形式だ。
参照実装の `libjxl` は3条項BSDライセンスで公開され、必要な特許についても「永続的・世界中で・無償・ロイヤリティフリー」という条件が付与されている(JPEG - JPEG XL:jpeg.org)。つまりJPEG XLがWindows標準に含まれていないのは、権利関係が理由ではない。既存のJPEG画像をビット単位で完全に復元できる「可逆再圧縮」のような魅力的な機能も備えている(libjxl README:GitHub)。
それでも普及が遅れているのは、AVIFのようにAV1という動画・ブラウザ・スマートフォン・ハードウェアデコードを含む巨大なエコシステムを持たないためだと考えられる。AV1のデコーダーに投資すれば動画にもAVIFにも使えるのに対し、JPEG XLのデコーダーはほぼJPEG XL専用にしかならない。OSメーカーからすれば、同じ投資でより広く使える技術のほうが優先されやすいというわけである。

以上をまとめると、画像形式がOS標準になるかどうかは、圧縮率や新しさだけでは決まらない。特許ライセンスの条件、既存のハードウェアでデコードできるか、動画コーデックなど他分野の技術と共通化できるかといった事情のほうが大きく効いていると考えられる。WebP がWindows標準に含まれていないのも、技術的な優劣ではなく、こうした事情の積み重ねの結果であろう。
また、業務利用やオープンソースとしての配布を想定しているなら、仕様作成の段階で、使用する機能・モジュールのライセンスについて調査をしておこう。

参考サイト

(この項おわり)
header