Newer
Older
TIASjudge / scripts / read_legacy_values.py
"""旧 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())