"""旧 exe のメイン画面から診断値を Win32 API で直接読み取る(回帰テスト採取の補助).
回帰テスト(TEST_01)では旧 exe が表示した L\\*a\\*b\\* と 5 色スコアを転記するが,
旧実装のバグにより **「紫」のスコアが画面上で読めない**.
`tonguediagnosisMFC2Dlg.cpp:683` の `font2.CreateFontA(150, ...)` は
`CreatePointFont(150, ...)`(= 15pt)と違って **高さ 150 ピクセル**のフォントを作るため,
`IDC_PURPLE` のラベルだけが巨大化して枠からはみ出し,値が判読できない.
ただし静的コントロールの**テキスト自体は正しく設定されている**ので,
`WM_GETTEXT` で文字列を直接吸い出せば正しい値が得られる.本スクリプトはそれを行う.
依存は Python 標準の `ctypes` のみ(pywin32 等は使わない).**Windows 専用**.
使い方(詳細は docs/06_TEST/TEST_01_回帰テスト採取手順.md):
1. 旧 exe を起動し,変換行列と単色 PNG を読み込んで «舌色診断» まで実行しておく
2. 旧 exe を**閉じずに**,リポジトリ直下で次を実行する
```bash
uv run python scripts/read_legacy_values.py
```
採取表(`tests/fixtures/regression/*.csv`)に貼れる形式で 8 値を表示する.
うまく読めないときのオプション:
```bash
# 起動中のトップレベルウィンドウのタイトル一覧を出す(ウィンドウ特定に失敗する場合)
uv run python scripts/read_legacy_values.py --list-windows
# 見つかったウィンドウの全子コントロールの生テキストを出す(パース失敗時のデバッグ用)
uv run python scripts/read_legacy_values.py --raw
# ウィンドウタイトルの一部を明示指定する(既定は "TIDA")
uv run python scripts/read_legacy_values.py --title "TIDA<診断>"
```
注意: 本スクリプトは**読み取り専用**で,旧 exe に対して値の書き換えや操作は行わない.
"""
from __future__ import annotations
import argparse
import ctypes
import re
import sys
from ctypes import wintypes
from dataclasses import dataclass, field
#: 旧 exe のウィンドウタイトルに含まれる文字列(部分一致で探す)
DEFAULT_TITLE_KEYWORD = "TIDA"
#: 採取表(`rgb_to_lab.csv` / `rgb_to_scores.csv`)に合わせた出力順
OUTPUT_FIELDS: tuple[str, ...] = (
"l_star",
"a_star",
"b_star",
"purple",
"dark_red",
"crimson",
"pink",
"pale",
)
#: 5 色スコアのラベル(旧実装 `m_xPurple = "紫 " + tempPu + "%"` 等).
#: 「紅」は「淡紅」「淡白紅」の一部でもあるため,**長いラベルから先に**照合する
SCORE_LABELS: tuple[tuple[str, str], ...] = (
("pale", "淡白紅"),
("pink", "淡紅"),
("dark_red", "暗赤"),
("purple", "紫"),
("crimson", "紅"),
)
#: L*a*b* のラベル(旧実装 `m_xOketsu = "L* " + tempO` 等)
LAB_LABELS: tuple[tuple[str, str], ...] = (
("l_star", "L*"),
("a_star", "a*"),
("b_star", "b*"),
)
#: 値部分(`%3.0lf` は右詰めなので前後に空白が入りうる.負値もありうる)
NUMBER_PATTERN = r"[+-]?\d+"
# --- Win32 API(ctypes.必要な分だけ宣言する)---
WM_GETTEXT = 0x000D
WM_GETTEXTLENGTH = 0x000E
SMTO_ABORTIFHUNG = 0x0002
#: 応答の無いウィンドウで固まらないための待ち時間(ミリ秒)
SEND_MESSAGE_TIMEOUT_MS = 2000
#: 1 コントロールから読み取る最大文字数(静的テキストなので十分に大きい)
MAX_TEXT_LENGTH = 1024
if sys.platform == "win32":
_user32 = ctypes.WinDLL("user32", use_last_error=True)
else: # pragma: no cover - Windows 以外では起動時に弾く
_user32 = None
WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def configure_api() -> None:
"""使用する API の引数・戻り値の型を設定する(64bit で値が壊れないようにする)."""
_user32.EnumWindows.argtypes = [WNDENUMPROC, wintypes.LPARAM]
_user32.EnumWindows.restype = wintypes.BOOL
_user32.EnumChildWindows.argtypes = [wintypes.HWND, WNDENUMPROC, wintypes.LPARAM]
_user32.EnumChildWindows.restype = wintypes.BOOL
_user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
_user32.GetWindowTextLengthW.restype = ctypes.c_int
_user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
_user32.GetWindowTextW.restype = ctypes.c_int
_user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
_user32.GetClassNameW.restype = ctypes.c_int
_user32.IsWindowVisible.argtypes = [wintypes.HWND]
_user32.IsWindowVisible.restype = wintypes.BOOL
_user32.SendMessageTimeoutW.argtypes = [
wintypes.HWND,
wintypes.UINT,
wintypes.WPARAM,
wintypes.LPARAM,
wintypes.UINT,
wintypes.UINT,
ctypes.POINTER(ctypes.c_size_t),
]
_user32.SendMessageTimeoutW.restype = wintypes.LPARAM
@dataclass
class Control:
"""読み取った子コントロール 1 個分の情報.
Attributes:
handle: ウィンドウハンドル(整数).
class_name: ウィンドウクラス名(Static / Edit 等).
text: 取得したテキスト.
"""
handle: int
class_name: str
text: str
@dataclass
class ParseResult:
"""パース結果.
Attributes:
values: 読み取れた項目(`OUTPUT_FIELDS` のキー → 整数値).
unmatched_numbers: どのラベルにも紐づかなかった数値だけのテキスト.
"""
values: dict[str, int] = field(default_factory=dict)
unmatched_numbers: list[str] = field(default_factory=list)
@property
def missing(self) -> list[str]:
"""読み取れなかった項目の一覧."""
return [name for name in OUTPUT_FIELDS if name not in self.values]
# --- ウィンドウ列挙・テキスト取得 ---
def window_text(handle: int) -> str:
"""ウィンドウのタイトル(キャプション)を返す."""
length = _user32.GetWindowTextLengthW(handle)
if length <= 0:
return ""
buffer = ctypes.create_unicode_buffer(length + 1)
_user32.GetWindowTextW(handle, buffer, length + 1)
return buffer.value
def _class_name(handle: int) -> str:
"""ウィンドウクラス名を返す."""
buffer = ctypes.create_unicode_buffer(256)
_user32.GetClassNameW(handle, buffer, 256)
return buffer.value
def control_text(handle: int) -> str:
"""`WM_GETTEXT` で子コントロールのテキストを読む.
旧 exe は ANSI(CP932)ビルドだが,`SendMessageTimeoutW`(W 版)を使えば
Windows 側が UTF-16 へ変換して返すため文字化けしない.
応答が無いウィンドウで固まらないよう,タイムアウト付きで送る.
"""
result = ctypes.c_size_t(0)
length_ok = _user32.SendMessageTimeoutW(
handle,
WM_GETTEXTLENGTH,
0,
0,
SMTO_ABORTIFHUNG,
SEND_MESSAGE_TIMEOUT_MS,
ctypes.byref(result),
)
if not length_ok:
return ""
length = min(int(result.value), MAX_TEXT_LENGTH)
if length <= 0:
return ""
buffer = ctypes.create_unicode_buffer(length + 1)
text_ok = _user32.SendMessageTimeoutW(
handle,
WM_GETTEXT,
length + 1,
ctypes.cast(buffer, ctypes.c_void_p).value or 0,
SMTO_ABORTIFHUNG,
SEND_MESSAGE_TIMEOUT_MS,
ctypes.byref(result),
)
if not text_ok:
return ""
return buffer.value
def list_top_level_windows(*, visible_only: bool = True) -> list[tuple[int, str]]:
"""トップレベルウィンドウの (ハンドル, タイトル) 一覧を返す."""
windows: list[tuple[int, str]] = []
def callback(handle: int, _param: int) -> bool:
if visible_only and not _user32.IsWindowVisible(handle):
return True
title = window_text(handle)
if title:
windows.append((handle, title))
return True
_user32.EnumWindows(WNDENUMPROC(callback), 0)
return windows
def find_window(keyword: str) -> tuple[int, str] | None:
"""タイトルに `keyword` を含む最初のウィンドウを返す(部分一致・大小文字無視)."""
needle = keyword.casefold()
for handle, title in list_top_level_windows():
if needle in title.casefold():
return handle, title
return None
def read_controls(handle: int) -> list[Control]:
"""指定ウィンドウの全子コントロール(孫以下も含む)のテキストを読む."""
controls: list[Control] = []
def callback(child: int, _param: int) -> bool:
controls.append(Control(child, _class_name(child), control_text(child)))
return True
_user32.EnumChildWindows(handle, WNDENUMPROC(callback), 0)
return controls
# --- パース ---
def parse_values(texts: list[str]) -> ParseResult:
"""コントロールのテキスト一覧から 8 値を取り出す.
読めた項目だけを `values` に入れ,読めなかった項目は `missing` で分かるようにする
(取りこぼしがあっても分かる範囲は出す方針).
Args:
texts: 子コントロールから読んだテキストの一覧.
Returns:
パース結果.
"""
result = ParseResult()
for raw in texts:
text = raw.replace("\u3000", " ").strip()
if not text:
continue
if _match_labeled(text, SCORE_LABELS, result) or _match_labeled(text, LAB_LABELS, result):
continue
# ラベル無しで数値だけのコントロール(旧実装は `m_purpleStr` 等も保持している)
if re.fullmatch(rf"\s*{NUMBER_PATTERN}\s*%?\s*", text):
result.unmatched_numbers.append(text.strip())
_apply_purple_fallback(result)
return result
def _match_labeled(text: str, labels: tuple[tuple[str, str], ...], result: ParseResult) -> bool:
"""`"紫 42%"` や `"L* -16"` の形からラベルと値を取り出して `result` に入れる."""
for name, label in labels:
# ラベルは文字列の先頭に来る(「紅」が「淡紅」の一部に一致しないよう前方一致で見る)
pattern = rf"^{re.escape(label)}\s*({NUMBER_PATTERN})\s*%?$"
matched = re.match(pattern, text)
if matched is None:
continue
# 同じラベルが複数あった場合は最初に読めた値を優先する
result.values.setdefault(name, int(matched.group(1)))
return True
return False
def _apply_purple_fallback(result: ParseResult) -> None:
"""紫がラベル付きで読めなかった場合に,数値だけのコントロールで補う.
巨大フォントのバグで «見た目» は壊れているがテキストは正しいはずなので,
通常はラベル付きで読める.ここは «ラベルごと壊れていた» 場合の保険であり,
候補が 1 つに絞れるときだけ採用する(誤った値を黙って使わないため).
"""
if "purple" in result.values:
return
candidates = [text for text in result.unmatched_numbers if text.strip("% ").lstrip("+-")]
if len(candidates) != 1:
return
number = re.search(NUMBER_PATTERN, candidates[0])
if number is None:
return
result.values["purple"] = int(number.group(0))
result.unmatched_numbers.remove(candidates[0])
print(
"警告: 「紫」をラベル無しの数値コントロールから推定しました"
f"(値: {result.values['purple']}).--raw で妥当性を確認してください.",
file=sys.stderr,
)
# --- 出力 ---
def format_csv_row(result: ParseResult) -> str:
"""採取表に貼れる 1 行を作る(読めなかった項目は空欄にする)."""
return ",".join(str(result.values.get(name, "")) for name in OUTPUT_FIELDS)
def print_result(result: ParseResult) -> None:
"""パース結果を人間が転記しやすい形で表示する."""
print("# 読み取り結果")
for name in OUTPUT_FIELDS:
value = result.values.get(name)
print(f" {name:8s} = {'(読めませんでした)' if value is None else value}")
print()
print(",".join(OUTPUT_FIELDS))
print(format_csv_row(result))
if result.missing:
print()
print(
f"警告: 次の項目を読み取れませんでした: {', '.join(result.missing)}",
file=sys.stderr,
)
print(
" → 旧 exe で «舌色診断» を実行済みか確認し,--raw で生テキストを確認してください.",
file=sys.stderr,
)
if result.unmatched_numbers:
print(
f"参考: ラベル無しの数値コントロール: {result.unmatched_numbers}",
file=sys.stderr,
)
def print_raw(controls: list[Control]) -> None:
"""全子コントロールの生テキストを表示する(パース失敗時のデバッグ用)."""
print(f"# 子コントロール {len(controls)} 個")
for control in controls:
text = control.text.replace("\r", "\\r").replace("\n", "\\n")
print(f" hwnd=0x{control.handle:08X} class={control.class_name:<16} text={text!r}")
def _build_parser() -> argparse.ArgumentParser:
"""コマンドライン引数のパーサを作る."""
parser = argparse.ArgumentParser(
description="旧 exe のメイン画面から診断値(L*a*b* と 5 色スコア)を読み取る.",
)
parser.add_argument(
"--title",
default=DEFAULT_TITLE_KEYWORD,
help=f"旧 exe のウィンドウタイトルに含まれる文字列(既定: {DEFAULT_TITLE_KEYWORD})",
)
parser.add_argument(
"--raw",
action="store_true",
help="全子コントロールの生テキストも表示する(パース失敗時のデバッグ用)",
)
parser.add_argument(
"--list-windows",
action="store_true",
help="トップレベルウィンドウのタイトル一覧を表示して終了する",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""エントリポイント.戻り値はプロセスの終了コード."""
arguments = _build_parser().parse_args(argv)
if sys.platform != "win32":
print(
"エラー: このスクリプトは Windows 専用です(Win32 API を使用します).",
file=sys.stderr,
)
return 1
configure_api()
if arguments.list_windows:
print("# トップレベルウィンドウ一覧")
for handle, title in list_top_level_windows():
print(f" hwnd=0x{handle:08X} title={title!r}")
return 0
found = find_window(arguments.title)
if found is None:
print(
f"エラー: タイトルに {arguments.title!r} を含むウィンドウが見つかりません.",
file=sys.stderr,
)
print(
" → 旧 exe を起動したまま実行してください."
"ウィンドウ名が違う場合は --title で指定するか,"
"--list-windows で一覧を確認してください.",
file=sys.stderr,
)
return 1
handle, title = found
print(f"# 対象ウィンドウ: {title!r} (hwnd=0x{handle:08X})")
controls = read_controls(handle)
if not controls:
print(
"エラー: 子コントロールを 1 つも取得できませんでした."
"別のウィンドウを掴んでいる可能性があります(--list-windows で確認).",
file=sys.stderr,
)
return 1
if arguments.raw:
print_raw(controls)
print()
result = parse_values([control.text for control in controls])
print_result(result)
# 8 値すべて読めたときだけ 0 を返す(採取の取りこぼしに気付けるようにする)
return 0 if not result.missing else 2
if __name__ == "__main__":
raise SystemExit(main())