Windowsのクリップボードを用いた選択コンテンツの取得
この記事では、「そのときCtrl+Cを押したときにクリップボードに格納されるコンテンツ」を、元のクリップボードの中身を保存したまま取得する方法について紹介する。これにより、例えば標準のクリップボードとは別の「スペア」のクリップボードを使ってコピー・切り取り・貼り付けを行うプログラムなどが書けるようになる。
言語は主にC#を使用。環境はWindows11、時期は2023年1月。
選択文字列を直接取得
本当は、クリップボードに一切手を付けず、直接的に選択されたコンテンツを得られるというのが理想であるが、そのような機能はない。テキストデータに限って言えば、SendMessageのWM_GETTEXTというのがあるが、これだとウインドウ(コントロール)全体、つまり選択されていない部分も含めてテキストボックス中の文字が全て取得されてしまう。
一応これはこれで役には立つかもしれないので参考情報だけ手短に。
c# - Get selected text using SendMessage - Stack Overflow
こちらに2バージョン(フォーカスのあるコントロールを対象にするものと、現在のカーソルの位置にある最も前面のコントロールを対象にするもの)あり、読みやすいので、これで十分だろう。ただし筆者が試したのは前者のみである。
また、実際にはこの方法で意図した通りにテキストボックス内の文字列を取得できるアプリケーションは少ない。メモ帳なら動くが、例えばVS CodeやTyporaだとウインドウタイトルが返ってくる(要するにフォーカスのあたっている対象がテキストボックスではなくウインドウ自体であると認識されてしまう)(Windows APIではウインドウとその内部のコントロール(テキストボックスなど)の扱いが一部共通である)。
Linuxのxsel
ちなみに、Linux(というかX Window System?)ではxsel
というコマンドで選択文字列の取得が可能であるが、試した限りWine上で動いているアプリケーションではうまく動作しなかった。他のだいたいのソフトでは問題ないはず。
クリップボードのバックアップ
ということで、クリップボードを使用することになる。要するに、クリップボードの内容をバックアップして、Ctrl+Cを送信して目的のコンテンツを取得し、元のバックアップを復元することができればいいということになる。
クリップボードのデータのタイプを例えばプレーンテキストなどと決め打ちできる場合は簡単である。Clipboard.GetTextでテキストデータを取得して一時的に変数に格納しておけばよい。画像なら画像、ファイルならファイルでそれに対応したメソッドを使えばよい。
しかし、どんなデータが格納されているかを事前に知ることなく一般にクリップボードのデータを確実に保存するよく知られた方法はない。以下のサイトによるとそもそもそういうことはあまり推奨されないらしい。
- https://stackoverflow.com/questions/6262454/c-sharp-backing-up-and-restoring-clipboard
- https://stackoverflow.com/questions/923323/how-to-preserve-the-contents-of-the-clipboard
ただ、手元でいくつか実験した限りでは必ず成功する方法を見つけることができたので、この記事ではそれを紹介する。
各フォーマットのデータの取得
まず、基本としては、Clipboard.GetDataObjectという、一般のデータに対応したメソッドを使用する。これを使うとIDataObjectというのを実装したオブジェクトが返ってくる。これは、(データの形式, その形式で表現したときのデータの内容)
の組のリストだと思えばよい。このようになっているのは、一般にクリップボードは複数の形式で使用できる必要があるからである(例えばテキストと画像が混在したWordからコピーして(テキストだけを)メモ帳に貼り付けられるのはこうした仕組みのおかげである)。なので、その「データの形式
」の中に例えばテキストデータ(DataFormats.Text
)が含まれているならば、そのIDataObjectに対してGetData(DataFormats.Text)
を使うことで、GetTextと全く同様にデータを取得できる。
素朴に考えれば、このIDataObjectを変数に格納しておいて後で復元すれば良さそうなものだが、それだと一部のケースでうまくいかなかった。例えばパワーポイントで図形や画像を複数選択してコピーしてから一旦終了した状態でバックアップ→復元とやると貼り付けられなくなってしまった。
- 「最後にコピーしたアイテムを貼り付けられる状態で保持しますか?」というメッセージが示唆する通り、Office系のソフトでは同じデータをコピーしたつもりでも起動中かどうかでクリップボードのデータ構造が変わるようである。
しかし、得たIDataObjectをそのまま使うのではなく、全ての形式に対して個別にGetDataして得られたデータを新しい空のIDataObjectに対してそれぞれSetDataするという方法でバックアップをとり、それをSetDataObjectで復元すると、なぜかうまくいくということが判明した。
コード例は以下である。
class CBSample
{
static string[] unsupported_formats = new string[] {"Object Descriptor"};
[STAThread]
public static void Main(string[] args)
{
new CBSample();
}
public CBSample()
{
System.Windows.Forms.IDataObject cb1 = new DataObject();
System.Windows.Forms.IDataObject cb2 = new DataObject();
MessageBox.Show("This app will save clipboard two times and then restore two times.");
this.backupCBto(ref cb1);
MessageBox.Show("clipboard 1 is saved.");
this.backupCBto(ref cb2);
MessageBox.Show("clipboard 2 is saved.");
this.restoreCBfrom(ref cb1);
MessageBox.Show("clipboard 1 is restored.");
this.restoreCBfrom(ref cb2);
MessageBox.Show("clipboard 2 is restored.");
}
private void backupCBto(ref System.Windows.Forms.IDataObject cb)
{
System.Windows.Forms.IDataObject cb_raw = Clipboard.GetDataObject();
//cb = cb_raw; return; //This sometimes work, but less versatile
cb = new DataObject();
foreach (string fmt in cb_raw.GetFormats(false))
{
if (!Array.Exists(unsupported_formats, s => s == fmt)) { Console.WriteLine(fmt); cb.SetData(fmt, cb_raw.GetData(fmt)); }
}
}
private void restoreCBfrom(ref System.Windows.Forms.IDataObject cb)
{
//the second argument is necessary only if you want to use clipboard data after this program terminates
Clipboard.SetDataObject(cb, true);
}
}
このサンプルでは、クリップボードを2回にわたって別々の変数に格納し、それを再び復元することができる。
また、詳しい原因は不明だが、ペイントやWordでコピーを実行したときにデータ形式の一つとして含まれる"Object Descriptor"に関して、これが含まれているとクリップボードの復元に失敗してしまうことがわかった。そこで上記コードではunsupported_formats
に"Object Descriptor"を指定し、GetDataの対象に含めないようにした。
- ペイントやWordは以前からテストにもよく使っていたし、以前は問題なく動いていた気がするのだが…時期としては2023年6月時点で不具合に気付いたので修正を行った。
- ところで、GetDataObject(に限らずClipboard関連関数全般?)はMainに
[STAThread]
が指定されていないとnullしか返ってこないので注意。
実験
アニメーション付きのPowerPointの図形を選択し、PowerPointを終了した場合、貼り付け時にアニメーションは消去される。これは上記サンプルでも通常のクリップボードでも変わらなかった。
Inkscapeで図形をコピーすると、ビットマップ形式も含めて多種多様な形式でクリップボードに保存しようとするため、数秒の待ち時間が発生し、Inkscape自体も重くなる。おそらく、Inkscapeでコピーを実行した段階ではInkscape自体が持っている本質的なデータへの参照のようなものがクリップボードに入るが、それに対して上記のようにGetDataを呼び出すと、その本質的なデータ自体が呼び出されて実際に複製されるために、重くなるのだろうと考えられる。(説明が下手…)
GetFormatsの引数は上の例ではfalseとなっている。ドキュメントによると、変換により取得可能なデータ形式まで含めるならtrue、そうでないならfalseとなっている。trueになっていると、(新規作成の「文書 1」の状態ではなく)既に名前を付けて保存済みのWordファイルに対してこれを用いてテキストをコピー&ペーストした際にコピー元を指すハイパーリンクとして貼り付けられてしまうという挙動をしたが、falseにすることでこれを防ぐことができた。一方で、Inkscapeのクリップボードデータの格納時の処理が重いのは改善がみられなかった。
他ソフトでの動作・実装
このような「選択コンテンツを取得する」という操作を伴うと思われる既存のソフトウェアもある。代表例はいわゆる「ポップアップ辞書」である。筆者自身も使用しているQTranslateというソフトで試してみたところ、Ctrl+Qで呼び出すタイミングでクリップボードの書き換えが検出されたほか、Inkscapeで図形データをコピーしてから実行すると非常に大きいタイムラグが生じたことから、同様のことを行っていると思われる。Office関連のデータでもクリップボードはきちんと維持されていた。
また同様のソフトであるCrow Translateは、手元で試してはいないがオープンソースなのでソースを見てみたところ、crow-translate/selection.cpp at master · crow-translate/crow-translateで同様のことをしているようである。QtのQClipboardというクラスはこのへんがうまく扱えるように設計されているようである。ちなみに、先ほどの「QTranslate」は名前からしていかにもQtで実装されていそうな感じがするがクローズドソースであるため詳細不明。
また、クリップボードの履歴を保存できるとされているCliborというソフトを試しに使ってみたが、クリップボードのデータがテキストに変換できるときに限って(変換した上で)履歴に保存するという動作であった。やはりクリップボードの内容を完全に維持する方法は広く普及してはいないのであろう。
余談 - Visual StudioでのC#と.NETのセットアップ
上記プログラムをVisual Studio (ここでは2022を使う)でビルドするのに一苦労した。
まずはVisual Studioインストール時のコンポーネント選択みたいなときに「.NET デスクトップ開発」を入れておく。
- ちなみに”.NET”と”.NET Framework”の違いについてだが、”.NET Framework”はバージョン4.8あたりで停止された古いものであり(しばらくサポートは続けられるようだが)、今後は”.NET”に移行していく。”.NET Core”は、”.NET”の初期の名前であり、”.NET 5”以降はCoreがつかなくなった。今は.NET 6あるいは7を使うことになるだろう。
これで「コンソール アプリ」というC#のプロジェクトが作れるようになる。
- 「ソリューションとプロジェクトを同じディレクトリに配置する」を選んだときになぜかプロジェクト無しのソリューションが作成されてしまったことがあった。手元ではもう再現できないので、VS2022を再起動などすれば治るかも。「同じディレクトリに配置する」は選ぶべきでないのかもしれない。
プロジェクトができたら、メインのProgram.csに先ほどのソースをそのまま貼り付けると、”Forms”が名前空間”System.Windows”に存在しない(CS0234)と言われる。そこで、プロジェクトファイル(.csproj)を開き(ソリューションエクスプローラーでプロジェクトを右クリックして「プロジェクト ファイルの編集」)、<TargetFramework>net6.0</TargetFramework>
のnet6.0
をnet6.0-windows
に変更する(.NET 7.0の場合も同様)。さらにこれの直後に<UseWindowsForms>true</UseWindowsForms>
と書く。これでエラーが消える。
- 参考: .NET アプリケーションでWindowsフォームを利用する | Yucchiy's Note
- こんなことをする必要があるのは、.NET (Core)ではFormsの使用は推奨されていないから、ということらしい。.NET5(.NET Core) で System.Windows.Forms を使用する
- なんで??それだったらクリップボードみたいなGUI関係ない(?)ものはFormsの外に出してくれませんかね…
また、後述の自作プログラムでKeyboard.IsKeyDownというのを使おうとしたところ、Keyboardの中にそんなものは無いと言われ、System.Windows.Input.Keyboardと指定したらこんどはSystem.Windows.InputにはKeyboardは無いと言われてしまった。
これは、
- c# - can't access Keyboard class under System.Windows.Input - Stack Overflow
- .NET Core/.NET5以降のコンソールアプリでWPFの機能を利用する - PG日誌
あたりに書いてある通り、WPFとかいうのを有効にしないとPresentationCore.dll
にあるものが使えないということらしいので、先ほどと同様にプロジェクトファイルを編集して<UseWPF>true</UseWPF>
を追加する。
クリップボード管理プログラム(自作)
この手法を活かして、スペアのクリップボードや選択テキストに対する操作を行うことができるようにしたhttps://github.com/ge9/clipboard-tweakというプログラムを作成した。丁寧な解説をするほどのものではないが、そちらのREADME.mdとあわせて多少の情報をメモ書き程度に提供しておく。
sendkeys.send
SendKeys.SendではUTF-16文字列を使うので、BMP外の文字を表示するにはサロゲートペアを用いるか、大文字UによるUnicodeリテラルを用いる。以下は「𩸽」をそれぞれの方法で表記してsendする例である。
SendKeys.Send("\U00029e3d and \xD867\xDE3D")
sendkeys.flush
よくわからないのでとりあえず頻繁に実行している。
グローバルなホットキー登録
グローバルというのは自分のアプリケーションに対する入力ではなくてもあらゆる場合にそのキー入力に反応するということである。RegisterHotKeyとUnRegisterHotKeyで登録/解除ができる。既に使われていると登録できない。
ホットキー(HotKey)の設定 (DllImport, InteropServices, RegisterHotKey, UnRegisterHotKey) - いろいろ備忘録日記
ウインドウを非表示でformを起動
https://www.codeproject.com/Questions/815453/How-to-create-an-invisible-Form-using-Windows-Form
に従い、コンストラクタの冒頭に
this.Visible = false;
this.WindowState = FormWindowState.Minimized;
this.ShowInTaskbar = false;
を入れると、一見消えているように見えるが、Alt+Tabなどをすると表示されているのが見えてしまうので、自分のHWNDを対象にSW_HIDEを指定してShowWindowをすると完全に消える。
case WM_SHOWWINDOW:
this.CenterToScreen();
if ((int)message.WParam == 1)
{
if (!window_init_done)
{//being shown
Console.WriteLine("Hiding");
window_init_done = true;
ShowWindow(this.Handle, SW_HIDE);
}
}
break;
conhostでの実行
しばしば実行が止まってしまうのでconhostで実行しないこと。Windows Terminalなどを用いる。詳細はWindowsでのターミナル環境を参照。
.NET (Framework)のバージョン
Windows 11などで普通にVisual Studioを入れると使われるバージョンが.NET 6などになってしまい、ランタイムが無いWindowsでは動かない。そこで、.NET Framework 4.6.2や3.5など、デフォルトのWindowsで動くバージョンを使ったビルドも提供している。
Windowsのバージョンと.NETのバージョンの対応状況はだいたい以下のような感じ。
Windowsや IIS や .NET などのバージョン対応メモ #Windows - Qiita
.NET Framework および Windows OS バージョン - .NET Framework | Microsoft Learn
.NET Framework Developer Pack または再頒布可能パッケージをインストールするには - .NET Framework | Microsoft Learn
3.5は古く、Windows 7を含む最も多くの環境で動作するが、クリップボードの一部の内容が失われる(Wordでやったときにフォント情報が消えるとか)ことがある。4.0以降ではこの問題はなさそうなので、4.0系の中で現在もサポートされている最も古いバージョンである4.6.2を使うのが良さそうである。
.NET Framework向けにビルドするには、まず該当のバージョンをVisual Studio InstallerあるいはVisual Studio 用の .NET SDK のダウンロードなどからインストールする(SDKがあればTargeting Packは多分不要)。プロジェクト作成時には「(.NET Framework)」と付いているものを選ぶ(参考: Targeted frameworks not showing options for .NET Framework 4.8 VS2022 - Microsoft Q&A)。ソースファイルは複数プロジェクトで共有できる(「既存の項目」の追加時に「リンクとして追加」する)(参考: .net - How do you share code between projects/solutions in Visual Studio? - Stack Overflow)。また、これに伴ってTupleなど一部の新しい機能を使わないようにソースを変更した。
- ValueTupleを.NET Frameworkで使うのは面倒そう。特に4.6.2以前。c# - Predefined type 'System.ValueTuple´2´ is not defined or imported - Stack Overflowなど
クリップボード履歴(Windows10以降)
Windows10で、設定を変更するとクリップボード履歴が使えるようになる。しかし、対応しているのは画像やテキストなど一部のフォーマットのみで、ファイルのコピーなどは履歴に登録されない。また個人的には、スペアのクリップボードを用意するほうが使い勝手がいい(場合もある)と思う。
AutoHotkey
autohotkeyでもクリップボード操作はできるが実装はそれほど完全ではなく、DataObjectを直接復元する場合(=あまりうまくいかない)以上のことはできない。
テキストデータなら問題なく復元できるようだが2秒程度の長いタイムラグがある。ペイントで範囲選択した画像は復元できなかった。
余談 - named pipeによるプロセス間通信
選択テキストをやりとりする仕組みを当初はAutoHotkeyとC#で分担して実装しようと思っていて、かつ操作のたびに新しいプロセスを起動したくはなかったので、プロセス間通信を使ってみることにした。
最もしっくり来るのはSendMessageだったのだが、ウインドウのないコンソールアプリケーションでどうやって使うのかよくわからなかったので、名前付きパイプを使ってみることにした。
今回は、読み書きそれぞれパイプを介して行う。
最初、パイプを作成した側から書き込む、というような感じで理解していて混乱したのだが、実際には、双方から読み書き可能なパイプというものがあり、これ1つあれば、どちらが最初にパイプを作るかと関係なく双方向の通信ができる(詳しくは以下で使われているCreateNamedPipe
の仕様を参照)。
このサンプルでは、C#のほうとAutoHotkeyのほうとどちらを先に起動したとしても、両方が起動したタイミングで正しく通信が開始する。C#から”XYZ”, AutoHotkeyからは”345”を送信し、お互いに相手から送られた内容を表示して終了する。
C#のNamedPipeClientStream
ではパイプの名前を"."
"test"
とコンピュータ名/パイプ名で分けているのに対して(AutoHotkeyで呼び出されている)Win32 APIの関数CreateNamedPipe
では"\\.\
pipe\
test"
と書く必要があるようなので注意。
using System;
using System.IO.Pipes;
namespace PipeSample
{
class Program
{
[STAThread]
public static void Main(string[] args)
{
NamedPipeClientStream npcs = new NamedPipeClientStream(".", "test", PipeDirection.InOut);
npcs.Connect();
byte[] dat = System.Text.Encoding.Unicode.GetBytes("XYZ");
byte[] buf = new byte[6];
if (npcs.IsConnected == true) {
npcs.Read(buf, 0, 6);
Console.WriteLine(System.Text.Encoding.Unicode.GetString(buf));
npcs.Write(dat, 0, 6);
npcs.WaitForPipeDrain();
npcs.Close();
npcs.Dispose();
}
}
}
}
ptr := A_PtrSize ? "Ptr" : "UInt"
char_size := A_IsUnicode ? 2 : 1
pipe := DllCall("CreateNamedPipe", "str", "\\.\pipe\test", "uint", 3, "uint", 4, "uint", 10, "uint", 100, "uint", 100, "uint", 0, ptr, 0, ptr)
DllCall("ConnectNamedPipe", ptr, pipe, ptr, 0)
MsgBox ConnectNamedPipe(0 is OK): %ErrorLevel%/%A_LastError%
VarSetCapacity(Buffer, char_size * 3)
DllCall("WriteFile", ptr, pipe, "str", "345", "uint", char_size * 3, "uint*", 0, ptr, 0)
DllCall("ReadFile", "UInt", pipe, "Str", Buffer, "UInt", char_size * 3, "UIntP", BytesRead, "UInt", 0)
MsgBox, 64, %BytesRead%, %Buffer%
なお、これは一応(Windows11で)動いた例というだけで、使用されている関数の細かいパラメータやエラーハンドリング、Closeの仕方、Unicode/非Unicodeや32bit/64bitの扱い、バイト数の計算の仕方など、不完全な部分がいくつもあると思われる。
一応参考にしたサンプルのリンクも貼るのでこちらもあわせて見ていただきたい。