diff --git a/src/content/blog-metas/2024-12-24-highdpi.json b/src/content/blog-metas/2024-12-24-highdpi.json index 1827f7c..db4c1a4 100644 --- a/src/content/blog-metas/2024-12-24-highdpi.json +++ b/src/content/blog-metas/2024-12-24-highdpi.json @@ -1,4 +1,4 @@ { "postDate": "2024-12-22T15:04:11.245Z", - "updateDate": "2024-12-24T12:25:07.303Z" + "updateDate": "2026-07-02T14:44:38.304Z" } diff --git a/src/content/blogs/2024-12-24-highdpi.md b/src/content/blogs/2024-12-24-highdpi.md index 2adf0a3..cddefc0 100644 --- a/src/content/blogs/2024-12-24-highdpi.md +++ b/src/content/blogs/2024-12-24-highdpi.md @@ -7,13 +7,13 @@ category: tech tags: [advent-calendar, c-cpp] --- -この記事は [OUCC Advent Calendar 2024](https://adventar.org/calendars/10655) の 24 日目の記事です。今回はちょっと早めに投稿しているので、昨日の記事は投稿時点ではまだ分かりません(その日になったらきっと追加されるはず)。本日は、Windows の High DPI 対応用 API の現状を簡単にまとめた上で、Win32 API 実装で古い OS との互換性を切らずに High DPI 対応する方法を説明します。 +この記事は [OUCC Advent Calendar 2024](https://adventar.org/calendars/10655) の 24 日目の記事です。昨日は Talkie さんの [M5StackでLCDに画像を表示する](../2024-12-23-m5stack/)でした。本日は、Windows の High DPI 対応用 API の現状を簡単にまとめた上で、Win32 API 実装で古い OS との互換性を切らずに High DPI 対応する方法を説明します。 この記事に掲載するプログラムコードは全て Public Domain ([CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0/deed.ja)) として扱っていただいて構いません。 ## Win32 API や High DPI について -Win32 API[^1] は **Windows における最低レイヤの API** です[^2]。低レベル API なのでフレームワークを使用する場合に比べて軽量で、Windows の全機能にアクセスできるため詳細なカスタマイズや高度な機能の利用が可能です。Windows 自体は主に C++ で書かれていますが、Win32 API は C 言語からでも使えます(もちろん C++ や Rust からでも使えます)。今では Web やら [Flutter](https://flutter.dev/) やら [.NET](https://dotnet.microsoft.com/) やら、一般的に何らかの外部フレームワークを使ってアプリを開発しますが、古くから開発が続いているアプリや、組み込みなどの特殊な環境を含む Windows 機に瞬時に展開できる軽量アプリや、Windows の機能を最大限活用したアプリを開発したいときなどに Win32 API を使います。ちなみに、Flutter などのフレームワーク類も内部的には Win32 API を呼び出しています。 +[Win32 API](https://learn.microsoft.com/en-us/windows/win32/desktop-programming)[^1] は **Windows における最低レイヤの API** です[^2]。低レベル API なのでフレームワークを使用する場合に比べて軽量で、Windows の全機能にアクセスできるため詳細なカスタマイズや高度な機能の利用が可能です。Windows 自体は主に C++ で書かれていますが、Win32 API は C 言語からでも使えます(もちろん C++ や Rust からでも使えます)。今では Web やら [Flutter](https://flutter.dev/) やら [.NET](https://dotnet.microsoft.com/) やら、一般的に何らかの外部フレームワークを使ってアプリを開発しますが、古くから開発が続いているアプリや、組み込みなどの特殊な環境を含む Windows 機に瞬時に展開できる軽量アプリや、Windows の機能を最大限活用したアプリを開発したいときなどに Win32 API を使います。ちなみに、Flutter などのフレームワーク類も内部的には Win32 API を呼び出しています。Win32 API プログラミングの基礎については、以前 Axross Recipe に寄稿した[はじめての Win32 API で GUI アプリを作るレシピ](https://axross-recipe.com/recipes/277)を参照してください(アカウント登録は必要ですが無料で読めます)。本記事では Win32 API の基礎は知っているものとします。 High DPI とは Windows 独自の概念で「**画面の細かさを表す仮想的な値 DPI が 96 より大きい環境**」です。DPI という語はプリンタの dots per inch に由来しますが、Windows では「**UI 要素を通常(96 DPI)と比べてどれだけ拡大すべきか**」を表します。ディスプレイ設定の表示サイズ関係のアレ(下図)で、近年の 4K 画面などの普及によりモニタごとに合わせたサイズで UI を描画する必要性が生じて導入されたものです。 @@ -21,14 +21,24 @@ High DPI とは Windows 独自の概念で「**画面の細かさを表す仮想 これには「対応アプリ」「非対応アプリ」という概念があり、非対応アプリは High DPI 画面ではピンぼけした見た目になります。Windows XP 時代には OS が勝手にフォントサイズなどを大きくして High DPI 対応していましたが、フォント形状などの都合で文字がはみだして隠れるなどのレイアウト崩壊を起こしていたので、原則として「**非対応アプリとして OS 側でピンぼけ拡大させる**」「**対応アプリとしてアプリ側が自分で全部やる**」ことになりました。 -ちなみに、Windows 10 Creators Update (1703) 以降では **GDI Scaling** という機能(Windows XP 方式を極力レイアウト崩壊しないように賢くやる)が実装されていて、これを使うと**単純なプログラムでは以下の manifest を付けるだけ**という、最小限のコストで High DPI 対応できます(以下の例では [Common Controls 6 (Visual Styles)](https://learn.microsoft.com/en-us/windows/win32/controls/themes-overview) も有効化しています)。Windows 2000 など、この辺りの機能に対応しない古い OS では単に無視されるだけなので、実行に際して問題は生じません。 +ちなみに、Windows 10 Creators Update (1703) 以降では **GDI Scaling** という機能(Windows XP 方式を極力レイアウト崩壊しないように賢くやる)が実装されていて、これを使うと**単純なプログラムでは以下の manifest を付けるだけ**という、最小限のコストで High DPI 対応できます(以下の例では [Common Controls 6 (Visual Styles)](https://learn.microsoft.com/en-us/windows/win32/controls/themes-overview) など、現在の必須設定も有効化しています)。Windows 2000 など、この辺りの機能に対応しない古い OS では単に無視されるだけなので、実行に際して問題は生じません。 ```xml + + My App - true + true + true + + SegmentHeap @@ -43,6 +53,22 @@ High DPI とは Windows 独自の概念で「**画面の細かさを表す仮想 /> + + + + + + + + + + + + + + + + ``` @@ -61,7 +87,7 @@ GDI Scaling を有効にしても Windows 10 Creators Update (1703) より前の
常に 96 DPI になる(OS 側でピンぼけ拡大)
System — Windows Vista 以降
-
常にプライマリディスプレイの DPI になる(サブディスプレイでは OS 側でピンぼけ拡大)
+
常にプライマリディスプレイの DPI になる(サブディスプレイでは OS 側でピンぼけ拡縮)
ログイン時に確定するため、DPI 設定やプライマリディスプレイを変えると再ログインするまで正常動作しない
Per-Monitor — Windows 8.1 以降
@@ -71,15 +97,25 @@ GDI Scaling を有効にしても Windows 10 Creators Update (1703) より前の
ウィンドウがあるモニタの DPI になる
-この中で最も「モダン」なのは **Per-Monitor V2** で、あらゆるモニタで最も綺麗に描画されます。この対応度合いを宣言するには、マニフェストに以下の内容を記述します(忘れてはいけないのは、**対応するのは自分自身**だということです)。[`dpiAware`](https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests#dpiAware) は古い OS 用の互換性のための記述で、新しい OS では [`dpiAwareness`](https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests#dpiAwareness) が優先されます。例によって High DPI を真面目にやるなら必須になる [Common Controls 6 (Visual Styles)](https://learn.microsoft.com/en-us/windows/win32/controls/themes-overview) も有効化しています。 +この中で最も「モダン」なのは **Per-Monitor V2** で、あらゆるモニタで最も綺麗に描画されます。この対応度合いを宣言するには、マニフェストに以下の内容を記述します(忘れてはいけないのは、**対応するのは自分自身**だということです)。[`dpiAware`](https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests#dpiAware) は古い OS 用の互換性のための記述で、新しい OS では [`dpiAwareness`](https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests#dpiAwareness) が優先されます。例によって High DPI を真面目にやるなら必須になる [Common Controls 6 (Visual Styles)](https://learn.microsoft.com/en-us/windows/win32/controls/themes-overview) など、現在の必須設定も有効化しています。 ```xml + + My App - True/PM - PerMonitorV2, PerMonitor + True/PM + PerMonitorV2, PerMonitor + true + + SegmentHeap @@ -94,6 +130,22 @@ GDI Scaling を有効にしても Windows 10 Creators Update (1703) より前の /> + + + + + + + + + + + + + + + + ``` @@ -142,9 +194,9 @@ Windows 2000 のような古い OS では単にこの window message が発出 ```c // 前提 -// * hWnd: 対象ウィンドウのハンドル -// * sysDPI: System DPI 値を保存するグローバル変数 -// * DPI: 現在の実効 DPI 値を保存するグローバル変数 +// * hWnd: 対象ウィンドウの HWND ハンドル +// * sysDPI: System DPI 値を保存する unsigned グローバル変数 +// * DPI: 現在の実効 DPI 値を保存する unsigned グローバル変数 // System DPI 値を取得する(LOGPIXELSX と LOGPIXELSY は同じ) // Fallback 値として使うほか、System DPI レベルしか対応しない Win32 API を扱うために使う @@ -170,9 +222,8 @@ if (shcore && user32) { GetDpiForMonitor_t getDpiForMonitor = (GetDpiForMonitor_t)(void *)GetProcAddress(shcore, "GetDpiForMonitor"); MonitorFromWindow_t monitorFromWindow = (MonitorFromWindow_t)(void *)GetProcAddress(user32, "MonitorFromWindow"); if (getDpiForMonitor && monitorFromWindow) { // 読み込めたら使う - unsigned tmpX, tmpY; // X と Y は同じ - getDpiForMonitor(monitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST), MDT_EFFECTIVE_DPI, &tmpX, &tmpY); - DPI = tmpX; + unsigned dummy; + getDpiForMonitor(monitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST), 0, &DPI, &dummy); goto dpidone; } } @@ -181,8 +232,8 @@ if (shcore && user32) { DPI = sysDPI; dpidone: -if (shcore) FreeLibrary(user32); -if (user32) FreeLibrary(shcore); +if (shcore) FreeLibrary(shcore); +if (user32) FreeLibrary(user32); // DPI 値に基づいてウィンドウサイズを変更する(サイズは適宜調整) // adjust() はグローバル変数 DPI を元に、96 DPI における値を実効 DPI における値に変換する関数として組まれている前提 @@ -192,17 +243,9 @@ SetWindowPos(hWnd, NULL, 0, 0, adjust(480), adjust(320), SWP_NOZORDER | SWP_NOAC このコードをコンパイルするには以下の定義が必要です。 ```c -typedef enum MONITOR_DPI_TYPE { - MDT_EFFECTIVE_DPI = 0, - MDT_ANGULAR_DPI = 1, - MDT_RAW_DPI = 2, - MDT_DEFAULT = MDT_EFFECTIVE_DPI -} MONITOR_DPI_TYPE; - -typedef UINT (WINAPI *GetDpiForWindow_t)(HWND hwnd); -typedef HRESULT (WINAPI *GetDpiForMonitor_t)(HMONITOR hmonitor, MONITOR_DPI_TYPE dpiType, UINT *dpiX, UINT *dpiY); -typedef HMONITOR (WINAPI *MonitorFromWindow_t)(HWND hwnd, DWORD dwFlags); - +typedef unsigned(WINAPI *GetDpiForWindow_t)(HWND hwnd); +typedef HRESULT(WINAPI *GetDpiForMonitor_t)(HMONITOR hmonitor, int dpiType, unsigned *dpiX, unsigned *dpiY); +typedef HMONITOR(WINAPI *MonitorFromWindow_t)(HWND hwnd, DWORD dwFlags); static inline int adjust(int coord) { return coord * DPI / 96; } ``` @@ -217,7 +260,7 @@ static inline int adjust(int coord) { return coord * DPI / 96; } - `WM_SIZE` で DPI 値を元に UI 要素を再配置する実装になっている前提 - 与えている引数の内容はあくまでも参考例 -アプリ全体で単一の DPI awareness を使う場合は、`GetDpiForWindow()` は不要なので以下の通り短縮できます([`MONITOR_DPI_TYPE`](https://learn.microsoft.com/en-us/windows/win32/api/shellscalingapi/ne-shellscalingapi-monitor_dpi_type) などの定義は依然必要です)。 +アプリ全体で単一の DPI awareness を使う場合は、`GetDpiForWindow()` は不要なので以下の通り短縮できます(`typedef` 等は依然必要です)。 ```c HDC hDC = GetDC(hWnd); @@ -231,9 +274,8 @@ MonitorFromWindow_t monitorFromWindow = NULL; if (user32) monitorFromWindow = (MonitorFromWindow_t)(void *)GetProcAddress(user32, "MonitorFromWindow"); if (getDpiForMonitor && monitorFromWindow) { - unsigned tmpX, tmpY; - getDpiForMonitor(monitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST), MDT_EFFECTIVE_DPI, &tmpX, &tmpY); - DPI = tmpX; + unsigned dummy; + getDpiForMonitor(monitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST), 0, &DPI, &dummy); } else { DPI = sysDPI; } @@ -255,6 +297,12 @@ if (kernel32) { } ``` +このコードをコンパイルするには以下の定義が必要です。 + +```c +typedef BOOL(WINAPI *SetDllDirectoryW_t)(const wchar_t *lpPathName); +``` + ちなみに、[`SetDllDirectoryW()`](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw) を直接呼び出さないのは Windows 2000 にこの API が実装されていないためなので、Windows XP 以降が対象なら単に `SetDllDirectoryW(L"");` だけで十分です。この機能を持たない Windows 2000 上では DLL hijacking に対して脆弱となりますが、そもそも今では脆弱性の塊みたいな OS なのでそこまで考えなくても良いでしょう。 ## 番外編:`MessageBoxW()` @@ -382,7 +430,7 @@ if (ret) { 本日は、Windows の High DPI 対応用 API の現状を簡単にまとめた上で、Win32 API 実装で古い OS との互換性を切らずに High DPI 対応する方法を説明しました。歴史的経緯で塗り固められた Windows の汚い部分に真正面から向き合うことになりますが、古い環境でも最新の環境でも完璧に振る舞えるアプリを開発できるのが C/C++ による Win32 API 直呼び出し実装の醍醐味だと思います。まあ、実用的なアプリを開発するには原始的すぎて今となっては非現実的ですが、古くから開発を続けているアプリの High DPI 対応化などの役に立てば幸いです。 -実際に Windows 2000 以降の互換性を維持しながら、Windows 11 でも完璧に Per-Monitor V2 High DPI 対応するように設計したソフトウェアとして、拙作の [Brainfuck インタプリタ](https://github.com/watamario15/brainfuck)があります(これはさらに preprocessor switch で [SHARP Brain 電子辞書](https://jp.sharp/edictionary/)を含む [Windows CE](https://ja.wikipedia.org/wiki/Microsoft_Windows_Embedded_CE) にも対応します)。まあ、2025 年を目前に控えて Windows 10 より古い OS をサポートする理由はもはや皆無なわけですが、ロマンがありますよね。 +実際に Windows 2000 以降の互換性を維持しながら、Windows 11 でも完璧に Per-Monitor V2 High DPI 対応するように設計したソフトウェアとして、拙作の [Brainfuck インタプリタ](https://github.com/watamario15/brainfuck)があります(これはさらに preprocessor switch で [SHARP Brain 電子辞書](https://jp.sharp/edictionary/)を含む [Windows CE](https://ja.wikipedia.org/wiki/Microsoft_Windows_Embedded_CE) にも対応します)。まあ、2025 年にもなって Windows 10 より古い OS をサポートする理由は皆無なわけですが、ロマンがありますよね。 Windows 11 200% (192 DPI): @@ -396,15 +444,16 @@ Windows 2000 100% (96 DPI): ![](2024-12-24-highdpi/win2000.png) -そんなわけで、私の今年の Advent Calendar 2024 記事はこれで最後です。良いお年を〜 +そんなわけで、私の Advent Calendar 2024 記事はこれで最後です。良いお年を〜 ## 参考サイト -[How to build high DPI aware native Windows desktop applications](https://mariusbancila.ro/blog/2021/05/19/how-to-build-high-dpi-aware-native-desktop-applications/) +- [はじめての Win32 API で GUI アプリを作るレシピ](https://axross-recipe.com/recipes/277) +- [How to build high DPI aware native Windows desktop applications](https://mariusbancila.ro/blog/2021/05/19/how-to-build-high-dpi-aware-native-desktop-applications/) -[^1]: [Microsoft としては現在では Windows API と呼んで欲しい](https://learn.microsoft.com/en-us/windows/win32/apiindex/windows-api-list)ようですが、現在の API 仕様に落ち着いたのが 32-bit Windows の頃だったことと、API 直接呼び出しによるアプリ開発が広くなされたのが 16-bit 時代から 32-bit 時代にかけてということもあり、Win32 API という呼び方が定着しています。というか、[Microsoft のドキュメント内でも Win32 API という説明が随所に見られる](https://learn.microsoft.com/en-us/windows/win32/apiindex/api-index-portal)ぐらいには、Microsoft 内でも派閥が分かれているようです。 -[^2]: 真の最低レイヤは UNIX 系 OS と同様に System Call ですが、仕様が非公開で OS バージョン毎に大幅に変化することから、ハッキング用途以外で使用することはありません。 +[^1]: Win32 というのは 16-bit から 32-bit に移行する際、Win16 に対して付けられたサブシステム名ですが、64-bit に移行する際は 32-bit の体系を概ねそのまま継承したため、64-bit 版も含めて引き続き Win32 と呼ばれています。Microsoft 内には、どうやら[引き続き Win32 API と呼ぶ派閥](https://learn.microsoft.com/en-us/windows/win32/desktop-programming)と [Windows API と呼ぶ派閥](https://learn.microsoft.com/en-us/windows/win32/apiindex/windows-api-list)があるようです。 +[^2]: 真の最低レイヤは UNIX 系 OS と同様に System Call ですが、仕様が非公開で OS バージョン毎に大幅に変化することから、ハッキング用途でしか使いません。 [^3]: この手のものを手動指定すると将来値が変わるかもと不安になるかもしれませんが、これはコンパイル時展開のマクロ定数なので、この値が変化すると `WM_DPICHANGED` マクロを使っていても結局再コンパイルが必要になります。つまり、単にコードの読みやすさだけの問題で、それも自分で定義するなりコメントを書くなりでカバーできる範囲です。 [^4]: Device context まで指定して呼び出す画面の pixels per inch を返す関数が、High DPI awareness のセーフガードまでついているにも関わらず、System DPI しか返さないのが謎でしかないのです。セーフガードがある時点で壊れる互換性なんてないだろうし、むしろ画面の pixels per inch を取れると期待するプログラムの互換性を壊しているはずです。 -[^5]: 2025 年を目前に控えた今では普通それで良いですが、「古い OS との互換性を切らずに High DPI 対応する」が本記事の趣旨なので。 +[^5]: 2025 年にもなった今では普通それで良いですが、「古い OS との互換性を切らずに High DPI 対応する」が本記事の趣旨なので。 [^6]: 何と Windows ではアプリに関連付けられた拡張子のファイルをダブルクリックすると、そのファイルの場所をカレントディレクトリとして起動します。つまり、攻撃者がアプリで開ける普通のファイルと共に偽の DLL を含む zip ファイルをユーザに渡し、ユーザがそれを丸ごと展開して普通のファイルを開いた場合、その横にある偽の DLL も `LoadLibraryW()` で読み込まれて実行されます。こんなとんでもないクソ仕様があるため脆弱性として対策が必要になります。