MFCで画面やウィンドウをキャプチャするときは、画面のDCからメモリDCに選択したビットマップへBitBltで写します。ただ、GetWindowRectで取った範囲をそのまま写すと、ウィンドウの左右と下に後ろの背景が写り込むことがあります。重なっている別のウィンドウを除いてウィンドウだけを取りたいときはPrintWindowを使い、DPI非対応のまま座標を取ると写す範囲がずれます。
6通りの取り込み方と結果
下の画面は、「検査結果」のウィンドウに、黄色の普通のウィンドウと、右上の半透明のウィンドウ(WS_EX_LAYERED)が重なった状態で、検査結果のウィンドウを6通りの方法で取り込んで並べたものです。

| 番号 | 取り込み方 | 結果 |
|---|---|---|
| 1 | 画面のDCからBitBlt(SRCCOPY)。範囲はGetWindowRect | 左・右・下に8ピクセルずつ後ろの背景が写る。重なったウィンドウも写る |
| 2 | 1にCAPTUREBLTを足した | 1と同じ(すべての画素が同じ色) |
| 3 | 1と同じだが、範囲はDwmGetWindowAttributeのDWMWA_EXTENDED_FRAME_BOUNDS | ウィンドウの見えている範囲だけ。重なったウィンドウは画面に見えているとおり写る |
| 4 | ウィンドウのDC(GetWindowDC)からBitBlt | 枠の内側はウィンドウ自身の絵で、重なったウィンドウは写らない。外側の8ピクセルには後ろの背景が写る |
| 5 | PrintWindow(フラグ0) | ウィンドウだけ。重なったウィンドウは写らない。見えない枠の所は枠の色で描かれる |
| 6 | DPI非対応にしたスレッドで1と同じ | 写す範囲が左上にずれ、小さくなる |
画面に見えているとおりに取りたいときは3、重なっているものを除いてウィンドウだけを取りたいときは5の書き方にします。
画面のDCからBitBltで写す
画面に表示されている内容は、画面全体のDCから読み出せます。GetDCにウィンドウのハンドルとしてNULLを渡すと、画面全体のDCが返ります。MFCでは、CClientDCのコンストラクターにNULLを渡すと、中で::GetDC(NULL)を呼び、デストラクターでReleaseDCを呼びます。このDCと互換のメモリDCとビットマップを作り、BitBltの転送元の座標に、写したい範囲の左上のスクリーン座標を渡します。
// 画面のDCから、スクリーン座標の範囲をビットマップに写す
static BOOL CaptureScreenRect(CBitmap& bmp, const CRect& rect, DWORD dwRop)
{
CClientDC dcScreen(NULL); // 画面全体のDC
CDC dcMem;
dcMem.CreateCompatibleDC(&dcScreen);
bmp.DeleteObject();
bmp.CreateCompatibleBitmap(&dcScreen, rect.Width(), rect.Height());
CBitmap* pOldBitmap = dcMem.SelectObject(&bmp);
const BOOL bResult = dcMem.BitBlt(0, 0, rect.Width(), rect.Height(),
&dcScreen, rect.left, rect.top, dwRop);
dcMem.SelectObject(pOldBitmap);
return bResult;
}
// 使う側(pWndは取り込みたいウィンドウ)
CRect rect;
pWnd->GetWindowRect(rect);
CBitmap bmp;
CaptureScreenRect(bmp, rect, SRCCOPY);
ビットマップはメモリDCから外した(元のビットマップを選択し直した)状態で返すので、そのまま別のDCに選択して描いたり、ファイルに保存したりできます。メモリDCに選択して画面へ描く流れは、BitBltとStretchBltの使い分けと同じです。画面のDCから写すので、取り込んだ画像はその瞬間に画面に表示されていた内容になり、上に重なっていたウィンドウも写ります(上の画面の1)。
GetWindowRectの範囲には見えない枠が入る
GetWindowRectの四角形には、Windows Vista以降、見えないサイズ変更用の枠が含まれることがあります。そのため上の画面の1では、ウィンドウの左・右・下に8ピクセルずつ、後ろのデスクトップや別のウィンドウが写りました。見えているウィンドウの範囲は、DwmGetWindowAttributeにDWMWA_EXTENDED_FRAME_BOUNDSを指定して取ります。
#include <dwmapi.h>
#pragma comment(lib, "dwmapi.lib")
// 見えている枠の範囲(スクリーン座標)を取る。取れなければGetWindowRectにする
CRect rect;
if (FAILED(::DwmGetWindowAttribute(pWnd->GetSafeHwnd(), DWMWA_EXTENDED_FRAME_BOUNDS,
&rect, sizeof(RECT))))
{
pWnd->GetWindowRect(rect);
}
CBitmap bmp;
CaptureScreenRect(bmp, rect, SRCCOPY);
GetWindowRectが(50, 50)から375×238だったウィンドウで、この値は(58, 50)から359×230になり、左・右・下が8ピクセルずつ狭くなりました。この範囲で写したものが上の画面の3で、ウィンドウの外側は写っていません。
ただし、DWMWA_EXTENDED_FRAME_BOUNDSの値はGetWindowRectと違ってDPIに合わせて調整されないので、DPI非対応のプロセスでは2つの値の単位がそろいません。取り込みをするアプリは、次の節のとおりDPI対応にしておきます。
DPI非対応のままだと範囲がずれる
上の画面の6は、SetThreadDpiAwarenessContextでスレッドをDPI非対応(DPI_AWARENESS_CONTEXT_UNAWARE)にして、1と同じことをした結果です。表示倍率125%(DPI 120)の画面で、GetWindowRectは(40, 40)から300×190を返しました。DPI対応のときの(50, 50)から375×238を、120分の96(125%の逆)にした値です。一方、画面のDCはこの数字をそのまま画面のピクセルとして使うので、ウィンドウより左上の300×190ピクセルが写り、ウィンドウの右と下が切れました。
表示倍率が100%の画面では2つの値が同じになるので、このずれは出ません。開発に使う画面が100%で、使う人の画面が125%や150%だと、使う人の所でだけ範囲がずれます。プロジェクトのプロパティで、マニフェストツールのDPIの設定(vcxprojではEnableDpiAwareness)をPer Monitor High DPI Awareにして、アプリをDPI対応にします。
半透明のウィンドウも写したいときはCAPTUREBLTを付ける
BitBltのCAPTUREBLTは、ウィンドウの上に重なっているレイヤードウィンドウ(WS_EX_LAYEREDのウィンドウ)を画像に含めるフラグで、付けないときは画像にウィンドウだけが含まれるとされています。半透明のウィンドウはレイヤードウィンドウで作ります。
上の画面の1はCAPTUREBLTを付けていませんが、半透明のウィンドウが写り、CAPTUREBLTを足した2とすべての画素が同じ色でした。説明と違う理由は分かっていません。Windowsの版や画面の設定で結果が変わらないとは言えないので、半透明のウィンドウも写したいときは、BitBltの最後の引数をSRCCOPY | CAPTUREBLTにしておきます。
重なったウィンドウを除いて取るにはPrintWindow
ウィンドウだけを取りたいときは、PrintWindowで、ウィンドウ自身にメモリDCへ描いてもらいます。MFCではCWnd::PrintWindowで呼べます。
// ウィンドウ自身に、メモリDCへ描いてもらう
static BOOL CapturePrintWindow(CBitmap& bmp, CWnd* pWnd)
{
CRect rect;
pWnd->GetWindowRect(rect);
CClientDC dcScreen(NULL);
CDC dcMem;
dcMem.CreateCompatibleDC(&dcScreen);
bmp.DeleteObject();
bmp.CreateCompatibleBitmap(&dcScreen, rect.Width(), rect.Height());
CBitmap* pOldBitmap = dcMem.SelectObject(&bmp);
const BOOL bResult = pWnd->PrintWindow(&dcMem, 0);
dcMem.SelectObject(pOldBitmap);
return bResult;
}
上の画面の5がこの結果で、黄色のウィンドウに隠れていた「合格」の所も描かれています。ビットマップはGetWindowRectと同じ大きさで、見えない枠の8ピクセルの所は枠の色で描かれます。フラグにPW_CLIENTONLYを渡すと、クライアント領域だけになります。
PrintWindowは、ウィンドウを持つアプリケーションが処理して描きます。また、すぐには戻らないことがあり、画面を扱うスレッドから呼ぶとアプリケーションが応答しなくなることがあります。
画面全体を取るときは仮想画面の座標を使う
モニターが複数あるときの画面全体は、すべてのモニターを囲む四角形(仮想画面)です。(0, 0)はプライマリモニターにあり、プライマリモニターが仮想画面の左上にないときは、仮想画面の一部が負の座標になります。そのため、画面全体は(0, 0)から始めずに、GetSystemMetricsで仮想画面の左上と幅・高さを取って写します。
// 仮想画面(すべてのモニターを囲む四角形)全体を写す
CRect rect(::GetSystemMetrics(SM_XVIRTUALSCREEN), ::GetSystemMetrics(SM_YVIRTUALSCREEN), 0, 0);
rect.right = rect.left + ::GetSystemMetrics(SM_CXVIRTUALSCREEN);
rect.bottom = rect.top + ::GetSystemMetrics(SM_CYVIRTUALSCREEN);
CBitmap bmp;
CaptureScreenRect(bmp, rect, SRCCOPY);
取り込んだ画像をPNGで保存する
取り込んだCBitmapは、ATLのCImageでPNGなどのファイルに保存できます(<atlimage.h>をインクルードします)。CImageにビットマップのハンドルをAttachして保存し、保存したらDetachで外します。外さないとCImageのデストラクターがビットマップを削除するので、CBitmapの側が使えなくなります。
CImage image;
image.Attach((HBITMAP)bmp.GetSafeHandle());
const HRESULT hr = image.Save(_T("C:\\Users\\Public\\Documents\\capture.png"), Gdiplus::ImageFormatPNG);
image.Detach();
まとめ
- CClientDC(NULL)の画面のDCから、メモリDCのビットマップへBitBltで写す。ウィンドウの範囲はGetWindowRectではなくDWMWA_EXTENDED_FRAME_BOUNDSで取る。
- 重なったウィンドウを除いてウィンドウだけを取るときはPrintWindowを使う。
- アプリはDPI対応にする。半透明のウィンドウも写したいときはCAPTUREBLTを付ける。
- 画面全体はGetSystemMetricsの仮想画面の値で範囲を作る。
