ascript 1.3.27__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. ascript/__init__.py +8 -0
  2. ascript/hooks/__init__.py +0 -0
  3. ascript/hooks/hook-ascript.py +2 -0
  4. ascript/windows/__init__.py +29 -0
  5. ascript/windows/daos/__init__.py +0 -0
  6. ascript/windows/daos/screen.py +143 -0
  7. ascript/windows/hardware/__init__.py +5 -0
  8. ascript/windows/hardware/keyboard.py +279 -0
  9. ascript/windows/hardware/logitech_driver.py +179 -0
  10. ascript/windows/hardware/mouse.py +308 -0
  11. ascript/windows/license/__init__.py +16 -0
  12. ascript/windows/license/achttp.cp310-win_amd64.pyd +0 -0
  13. ascript/windows/license/achttp.cp311-win_amd64.pyd +0 -0
  14. ascript/windows/license/achttp.cp312-win_amd64.pyd +0 -0
  15. ascript/windows/license/achttp.cp313-win_amd64.pyd +0 -0
  16. ascript/windows/license/achttp.cp314-win_amd64.pyd +0 -0
  17. ascript/windows/license/achttp.cp38-win_amd64.pyd +0 -0
  18. ascript/windows/license/achttp.cp39-win_amd64.pyd +0 -0
  19. ascript/windows/media/__init__.py +0 -0
  20. ascript/windows/resources/__init__.py +0 -0
  21. ascript/windows/resources/libs/logitech/x64/LogitechGkeyEnginesWrapper.dll +0 -0
  22. ascript/windows/resources/libs/logitech/x86/LogitechGkeyEnginesWrapper.dll +0 -0
  23. ascript/windows/resources/templates/__init__.py +0 -0
  24. ascript/windows/resources/templates/__init__py.win.template +51 -0
  25. ascript/windows/resources/templates/build.as.win.template +14 -0
  26. ascript/windows/resources/templates/build.py +71 -0
  27. ascript/windows/resources/templates/logo_windows.ico +0 -0
  28. ascript/windows/resources/templates/windows/main.py +50 -0
  29. ascript/windows/resources/templates/windows/pyproject.toml +44 -0
  30. ascript/windows/resources/templates/windows/res/img/logo.ico +0 -0
  31. ascript/windows/screen/__init__.py +13 -0
  32. ascript/windows/screen/as_cv.py +382 -0
  33. ascript/windows/screen/canny.py +128 -0
  34. ascript/windows/screen/code_scanner.py +72 -0
  35. ascript/windows/screen/color_algorithms.py +277 -0
  36. ascript/windows/screen/find_colors.py +251 -0
  37. ascript/windows/screen/find_images.py +217 -0
  38. ascript/windows/screen/ocr/__init__.py +4 -0
  39. ascript/windows/screen/ocr/ocr.py +223 -0
  40. ascript/windows/screen/ocr/rapidocr_hook.py +301 -0
  41. ascript/windows/screen/ocrx.py +169 -0
  42. ascript/windows/screen/screen_core.py +139 -0
  43. ascript/windows/store/__init__.py +21 -0
  44. ascript/windows/system/__init__.py +8 -0
  45. ascript/windows/system/device.py +355 -0
  46. ascript/windows/system/easy_print.py +115 -0
  47. ascript/windows/system/env.py +492 -0
  48. ascript/windows/system/exception/__init__.py +1 -0
  49. ascript/windows/system/key_value.py +145 -0
  50. ascript/windows/system/pip.py +175 -0
  51. ascript/windows/system/r.py +165 -0
  52. ascript/windows/system/runtime.py +62 -0
  53. ascript/windows/tools/__init__.py +27 -0
  54. ascript/windows/tools/__main__.py +21 -0
  55. ascript/windows/tools/action.json +33 -0
  56. ascript/windows/tools/action.py +168 -0
  57. ascript/windows/tools/colors_tool.py +391 -0
  58. ascript/windows/tools/config.py +26 -0
  59. ascript/windows/tools/dumptool.py +18 -0
  60. ascript/windows/tools/eel_api.py +495 -0
  61. ascript/windows/tools/gpplug/__init__.py +0 -0
  62. ascript/windows/tools/gpplug/plug.py +70 -0
  63. ascript/windows/tools/mouse_catch.py +168 -0
  64. ascript/windows/tools/plug_manager.py +38 -0
  65. ascript/windows/tools/tool_api.py +906 -0
  66. ascript/windows/tools/tool_window.py +56 -0
  67. ascript/windows/tools/uia_worker.py +365 -0
  68. ascript/windows/tools/web/app_store_window.html +481 -0
  69. ascript/windows/tools/web/colors.html +532 -0
  70. ascript/windows/tools/web/css/screen.css +1342 -0
  71. ascript/windows/tools/web/css/viewtree.css +357 -0
  72. ascript/windows/tools/web/flot_control.html +195 -0
  73. ascript/windows/tools/web/img/ico/favicon.png +0 -0
  74. ascript/windows/tools/web/img/ico/paintpalette.png +0 -0
  75. ascript/windows/tools/web/img/svg/find_colors.svg +1 -0
  76. ascript/windows/tools/web/img/svg/find_images.svg +1 -0
  77. ascript/windows/tools/web/img/svg/illustration-6.svg +89 -0
  78. ascript/windows/tools/web/img/svg/img_default.svg +1 -0
  79. ascript/windows/tools/web/img/svg/ocr.svg +1 -0
  80. ascript/windows/tools/web/js/screen/colors.js +466 -0
  81. ascript/windows/tools/web/js/screen/gp_plug.js +108 -0
  82. ascript/windows/tools/web/js/screen/image_mouse.js +464 -0
  83. ascript/windows/tools/web/js/screen/img_view.js +558 -0
  84. ascript/windows/tools/web/js/screen/plug.js +246 -0
  85. ascript/windows/tools/web/js/screen/screen.js +690 -0
  86. ascript/windows/tools/web/js/screen/screen_api.js +778 -0
  87. ascript/windows/tools/web/js/screen/screen_maker.js +10 -0
  88. ascript/windows/tools/web/js/utils/map.js +73 -0
  89. ascript/windows/tools/web/js/utils/upload.js +157 -0
  90. ascript/windows/tools/web/js/viewtree/niv_js.js +27 -0
  91. ascript/windows/tools/web/js/viewtree/vtree.js +1510 -0
  92. ascript/windows/tools/web/libs/bootstrap/css/bootstrap.min.css +6 -0
  93. ascript/windows/tools/web/libs/bootstrap/js/bootstrap.bundle.min.js +7 -0
  94. ascript/windows/tools/web/libs/bootstrap-icons/0-circle.svg +4 -0
  95. ascript/windows/tools/web/libs/bootstrap-icons/0-square.svg +4 -0
  96. ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.css +2078 -0
  97. ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.json +2052 -0
  98. ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.min.css +5 -0
  99. ascript/windows/tools/web/libs/bootstrap-icons/font/bootstrap-icons.scss +2090 -0
  100. ascript/windows/tools/web/libs/bootstrap-icons/font/fonts/bootstrap-icons.woff +0 -0
  101. ascript/windows/tools/web/libs/bootstrap-icons/font/fonts/bootstrap-icons.woff2 +0 -0
  102. ascript/windows/tools/web/libs/feather-icons/dist/feather.min.js +13 -0
  103. ascript/windows/tools/web/libs/feather-icons/dist/icons.json +1 -0
  104. ascript/windows/tools/web/libs/jquery/core.js +399 -0
  105. ascript/windows/tools/web/libs/jquery/jquery.js +10598 -0
  106. ascript/windows/tools/web/libs/jquery/jquery.min.js +4 -0
  107. ascript/windows/tools/web/libs/jquery/jquery.min.js.zip +0 -0
  108. ascript/windows/tools/web/libs/jquery/jquery.slim.js +8495 -0
  109. ascript/windows/tools/web/libs/jquery/jquery.slim.min.js +2 -0
  110. ascript/windows/tools/web/libs/jquery-menu/jquery.contextMenu.js +2134 -0
  111. ascript/windows/tools/web/libs/jquery-menu/jquery.contextMenu.min.css +16 -0
  112. ascript/windows/tools/web/libs/jquery-zTree/css/zTreeStyle.css +97 -0
  113. ascript/windows/tools/web/libs/jquery-zTree/img/diy/1_close.png +0 -0
  114. ascript/windows/tools/web/libs/jquery-zTree/img/diy/1_open.png +0 -0
  115. ascript/windows/tools/web/libs/jquery-zTree/img/diy/2.png +0 -0
  116. ascript/windows/tools/web/libs/jquery-zTree/img/diy/3.png +0 -0
  117. ascript/windows/tools/web/libs/jquery-zTree/img/diy/4.png +0 -0
  118. ascript/windows/tools/web/libs/jquery-zTree/img/diy/5.png +0 -0
  119. ascript/windows/tools/web/libs/jquery-zTree/img/diy/6.png +0 -0
  120. ascript/windows/tools/web/libs/jquery-zTree/img/diy/7.png +0 -0
  121. ascript/windows/tools/web/libs/jquery-zTree/img/diy/8.png +0 -0
  122. ascript/windows/tools/web/libs/jquery-zTree/img/diy/9.png +0 -0
  123. ascript/windows/tools/web/libs/jquery-zTree/img/line_conn.gif +0 -0
  124. ascript/windows/tools/web/libs/jquery-zTree/img/loading.gif +0 -0
  125. ascript/windows/tools/web/libs/jquery-zTree/img/tree-node-btn.png +0 -0
  126. ascript/windows/tools/web/libs/jquery-zTree/img/zTreeStandard.gif +0 -0
  127. ascript/windows/tools/web/libs/jquery-zTree/img/zTreeStandard.png +0 -0
  128. ascript/windows/tools/web/libs/jquery-zTree/js/jquery.ztree.all.min.js +3 -0
  129. ascript/windows/tools/web/libs/treeview/css/jquery.treeview.css +79 -0
  130. ascript/windows/tools/web/libs/treeview/img/file.png +0 -0
  131. ascript/windows/tools/web/libs/treeview/img/ico_loading.png +0 -0
  132. ascript/windows/tools/web/libs/treeview/img/icon4.png +0 -0
  133. ascript/windows/tools/web/libs/treeview/img/icon5.png +0 -0
  134. ascript/windows/tools/web/libs/treeview/img/icon6.png +0 -0
  135. ascript/windows/tools/web/libs/treeview/img/py.png +0 -0
  136. ascript/windows/tools/web/libs/treeview/img/treeview-black-line.gif +0 -0
  137. ascript/windows/tools/web/libs/treeview/img/treeview-black.gif +0 -0
  138. ascript/windows/tools/web/libs/treeview/img/treeview-default-line.gif +0 -0
  139. ascript/windows/tools/web/libs/treeview/img/treeview-default.gif +0 -0
  140. ascript/windows/tools/web/libs/treeview/img/treeview-famfamfam-line.gif +0 -0
  141. ascript/windows/tools/web/libs/treeview/img/treeview-famfamfam.gif +0 -0
  142. ascript/windows/tools/web/libs/treeview/img/treeview-gray-line.gif +0 -0
  143. ascript/windows/tools/web/libs/treeview/img/treeview-gray.gif +0 -0
  144. ascript/windows/tools/web/libs/treeview/img/treeview-red-line.gif +0 -0
  145. ascript/windows/tools/web/libs/treeview/img/treeview-red.gif +0 -0
  146. ascript/windows/tools/web/libs/treeview/js/jquery.treeview.edit.js +37 -0
  147. ascript/windows/tools/web/libs/treeview/js/jquery.treeview.js +250 -0
  148. ascript/windows/tools/web/license_window.html +255 -0
  149. ascript/windows/tools/web/plugs/a.html +653 -0
  150. ascript/windows/tools/web/plugs/demo.html +52 -0
  151. ascript/windows/tools/web/plugs/find_colors.html +629 -0
  152. ascript/windows/tools/web/plugs/find_images.html +699 -0
  153. ascript/windows/tools/web/plugs/rapidocr.html +954 -0
  154. ascript/windows/tools/web/vtree.html +496 -0
  155. ascript/windows/tools/webview/assets/index-HymBPl-I.css +1 -0
  156. ascript/windows/tools/webview/assets/index-PqA_m8tL.js +17 -0
  157. ascript/windows/tools/webview/index.html +13 -0
  158. ascript/windows/ui/__init__.py +6 -0
  159. ascript/windows/ui/app_store_window.py +272 -0
  160. ascript/windows/ui/draw_window.py +231 -0
  161. ascript/windows/ui/flot_test.py +297 -0
  162. ascript/windows/ui/flot_window.py +488 -0
  163. ascript/windows/ui/license_window.py +111 -0
  164. ascript/windows/ui/web_window.py +466 -0
  165. ascript/windows/utils/__init__.py +0 -0
  166. ascript/windows/utils/node_utils.py +20 -0
  167. ascript/windows/utils/screen_utils.py +280 -0
  168. ascript/windows/window/Selector.py +1015 -0
  169. ascript/windows/window/Window.py +747 -0
  170. ascript/windows/window/__init__.py +5 -0
  171. ascript/windows/window/launcher.py +213 -0
  172. ascript/windows/window/uia_runtime.py +26 -0
  173. ascript-1.3.27.dist-info/METADATA +41 -0
  174. ascript-1.3.27.dist-info/RECORD +177 -0
  175. ascript-1.3.27.dist-info/WHEEL +5 -0
  176. ascript-1.3.27.dist-info/entry_points.txt +2 -0
  177. ascript-1.3.27.dist-info/top_level.txt +1 -0
@@ -0,0 +1,906 @@
1
+ import base64
2
+ import io
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import uuid
9
+ from datetime import datetime
10
+ from functools import lru_cache
11
+ from typing import Any, Dict, Optional
12
+
13
+ from ..screen import (
14
+ Canny, CodeScanner, Colors, CompareColors, FindBlock, FindColors, FindImages,
15
+ Ocr, OcrX, Screen,
16
+ )
17
+ from ..screen.as_cv import AsCv
18
+ from ..window import Window
19
+
20
+
21
+ class ToolApi:
22
+ """UI-independent API used by the SDK's WebView2 tools."""
23
+
24
+ def __init__(self, tool: str, initial_hwnd: Optional[int] = None):
25
+ self.tool = tool
26
+ self.initial_hwnd = int(initial_hwnd) if initial_hwnd else None
27
+ self._last_image = None
28
+ self._last_image_bytes = None
29
+ self._active_image_path = None
30
+ project_root = os.environ.get("ASCRIPT_PROJECT_ROOT", "").strip()
31
+ self._project_root = os.path.realpath(project_root) if project_root else ""
32
+ self._project_name = os.environ.get("ASCRIPT_PROJECT_NAME", "")
33
+ self._workspace_root = os.path.realpath(os.path.dirname(self._project_root)) if self._project_root else ""
34
+ self._image_templates = {}
35
+ data_root = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
36
+ self._capture_dir = os.path.join(data_root, "AScript", "captures")
37
+ os.makedirs(self._capture_dir, exist_ok=True)
38
+ self._working_dir = os.path.join(self._capture_dir, "working")
39
+ os.makedirs(self._working_dir, exist_ok=True)
40
+ self._template_dir = os.path.join(self._working_dir, f"templates-{os.getpid()}")
41
+ os.makedirs(self._template_dir, exist_ok=True)
42
+
43
+ def get_context(self) -> Dict[str, Any]:
44
+ return {
45
+ "tool": self.tool,
46
+ "initialHwnd": self.initial_hwnd,
47
+ "captureDir": self._capture_dir,
48
+ "projectRoot": self._project_root or None,
49
+ "projectName": self._project_name or None,
50
+ "projects": self._workspace_projects(),
51
+ }
52
+
53
+ def _workspace_projects(self):
54
+ if not self._workspace_root or not os.path.isdir(self._workspace_root):
55
+ return []
56
+ projects = []
57
+ try:
58
+ for name in sorted(os.listdir(self._workspace_root), key=str.casefold):
59
+ if not re.fullmatch(r"[\w\u4e00-\u9fff-]{1,64}", name, re.UNICODE):
60
+ continue
61
+ path = os.path.realpath(os.path.join(self._workspace_root, name))
62
+ if os.path.dirname(path) != self._workspace_root or not os.path.isdir(path):
63
+ continue
64
+ if os.path.isfile(os.path.join(path, "__init__.py")):
65
+ projects.append({"name": name, "path": path})
66
+ except OSError:
67
+ return []
68
+ return projects
69
+
70
+ def _resolve_project_root(self, project_name=None):
71
+ name = str(project_name or self._project_name).strip()
72
+ if not name:
73
+ raise ValueError("请选择要保存到的工程")
74
+ if not self._workspace_root or not re.fullmatch(r"[\w\u4e00-\u9fff-]{1,64}", name, re.UNICODE):
75
+ raise ValueError("工程名称无效")
76
+ root = os.path.realpath(os.path.join(self._workspace_root, name))
77
+ if os.path.dirname(root) != self._workspace_root or not os.path.isfile(os.path.join(root, "__init__.py")):
78
+ raise ValueError("所选工程不存在或不在当前工作区")
79
+ return root, name
80
+
81
+ @staticmethod
82
+ @lru_cache(maxsize=256)
83
+ def _product_name(process_path: str) -> str:
84
+ """Read the executable's localized ProductName from Windows version info."""
85
+ if not process_path or process_path == "Unknown":
86
+ return ""
87
+ try:
88
+ import win32api
89
+
90
+ raw = win32api.GetFileVersionInfo(process_path, "\\VarFileInfo\\Translation")
91
+ if isinstance(raw, str):
92
+ values = [ord(char) for char in raw]
93
+ translations = list(zip(values[0::2], values[1::2]))
94
+ elif isinstance(raw, (bytes, bytearray)):
95
+ translations = [
96
+ (int.from_bytes(raw[index:index + 2], "little"),
97
+ int.from_bytes(raw[index + 2:index + 4], "little"))
98
+ for index in range(0, len(raw) - 3, 4)
99
+ ]
100
+ else:
101
+ translations = list(raw or [])
102
+ for language, codepage in translations:
103
+ if not (0 <= int(language) <= 0xFFFF and 0 <= int(codepage) <= 0xFFFF):
104
+ continue
105
+ path = f"\\StringFileInfo\\{language:04x}{codepage:04x}\\ProductName"
106
+ value = str(win32api.GetFileVersionInfo(process_path, path) or "").strip()
107
+ if value:
108
+ return value
109
+ except Exception:
110
+ pass
111
+ return ""
112
+
113
+ @classmethod
114
+ def _application_name(cls, window: Window, process_path: str) -> str:
115
+ product_name = cls._product_name(process_path)
116
+ if product_name:
117
+ return product_name
118
+ process_name = window.process_name or ""
119
+ stem = os.path.splitext(process_name)[0].strip()
120
+ known_apps = {
121
+ "chrome": "Google Chrome",
122
+ "msedge": "Microsoft Edge",
123
+ "firefox": "Mozilla Firefox",
124
+ "notepad": "Notepad",
125
+ "explorer": "File Explorer",
126
+ }
127
+ if stem.casefold() in known_apps:
128
+ return known_apps[stem.casefold()]
129
+ generic_hosts = {"applicationframehost", "shellexperiencehost", "systemsettings"}
130
+ if stem.casefold() in generic_hosts and window.title:
131
+ return window.title.split(" - ", 1)[0].strip()
132
+ return stem or window.title or "未知应用"
133
+
134
+ def get_windows(self) -> Dict[str, Any]:
135
+ try:
136
+ items = []
137
+ for window in Window.find_all(visible_only=True):
138
+ if window.title:
139
+ process_path = window.process_path
140
+ app_name = self._application_name(window, process_path)
141
+ items.append({
142
+ "display": f"{window.title} [{window.hwnd}]",
143
+ "hwnd": window.hwnd,
144
+ "title": window.title,
145
+ "app_name": app_name,
146
+ "process_name": window.process_name,
147
+ "process_path": process_path,
148
+ "class_name": window.class_name,
149
+ })
150
+ items.sort(key=lambda item: (
151
+ item["app_name"].casefold(), item["title"].casefold(), item["hwnd"]
152
+ ))
153
+ return {"status": "success", "data": items}
154
+ except Exception as exc:
155
+ return {"status": "error", "message": str(exc), "data": []}
156
+
157
+ def inspect_window(self, hwnd: int, max_depth: int = 0) -> Dict[str, Any]:
158
+ try:
159
+ target = Window(int(hwnd))
160
+ if not target.title:
161
+ return {"status": "error", "message": "窗口已关闭或句柄无效"}
162
+ depth = max(1, min(int(max_depth or 12), 30))
163
+ result = self._run_uia_worker(
164
+ ["inspect", "--hwnd", str(target.hwnd), "--depth", str(depth)],
165
+ timeout=25,
166
+ )
167
+ if result.get("status") == "success":
168
+ return result
169
+ fallback = self._native_window_tree(
170
+ target, result.get("message") or "目标窗口未暴露 UIA 控件"
171
+ )
172
+ return fallback if fallback.get("status") == "success" else result
173
+ except Exception as exc:
174
+ return {"status": "error", "message": str(exc)}
175
+
176
+ @staticmethod
177
+ def _run_uia_worker(arguments, timeout: int, input_text: str = ""):
178
+ command = [sys.executable, "-u", "-m", "ascript.windows.tools.uia_worker", *arguments]
179
+ try:
180
+ completed = subprocess.run(
181
+ command, input=input_text, capture_output=True, text=True,
182
+ encoding="utf-8", errors="replace", timeout=timeout,
183
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
184
+ )
185
+ except subprocess.TimeoutExpired:
186
+ return {"status": "error", "message": f"UI Automation 探测超过 {timeout} 秒,已终止隔离进程"}
187
+ prefix = "ASCRIPT_UIA_RESULT="
188
+ for line in reversed(completed.stdout.splitlines()):
189
+ if line.startswith(prefix):
190
+ try:
191
+ import json
192
+ return json.loads(line[len(prefix):])
193
+ except Exception as exc:
194
+ return {"status": "error", "message": f"UIA 结果解析失败: {exc}"}
195
+ message = completed.stderr.strip() or completed.stdout.strip()
196
+ return {"status": "error", "message": message or "UIA 隔离进程异常退出"}
197
+
198
+ @staticmethod
199
+ def _count_nodes(node: Dict[str, Any]) -> int:
200
+ return 1 + sum(ToolApi._count_nodes(child) for child in node.get("children", []))
201
+
202
+ @staticmethod
203
+ def _native_window_tree(target: Window, reason: str) -> Dict[str, Any]:
204
+ """Fallback to real child HWNDs when the application exposes no useful UIA tree."""
205
+ import win32gui
206
+
207
+ root_hwnd = int(target.hwnd)
208
+ client = target.client_rect
209
+ handles = []
210
+ win32gui.EnumChildWindows(root_hwnd, lambda hwnd, output: output.append(hwnd), handles)
211
+ elements = {}
212
+ for hwnd in handles:
213
+ try:
214
+ rect = win32gui.GetWindowRect(hwnd)
215
+ elements[hwnd] = {
216
+ "name": win32gui.GetWindowText(hwnd) or "",
217
+ "type": "NativeWindow",
218
+ "res_id": "",
219
+ "memory_id": int(hwnd),
220
+ "class_name": win32gui.GetClassName(hwnd),
221
+ "handle": int(hwnd),
222
+ "rect": {
223
+ "left": rect[0] - client.left,
224
+ "top": rect[1] - client.top,
225
+ "right": rect[2] - client.left,
226
+ "bottom": rect[3] - client.top,
227
+ },
228
+ "enabled": bool(win32gui.IsWindowEnabled(hwnd)),
229
+ "visible": bool(win32gui.IsWindowVisible(hwnd)),
230
+ "clickable": False,
231
+ "value": "",
232
+ "description": "",
233
+ "password": False,
234
+ "checkable": False,
235
+ "checked": False,
236
+ "editable": False,
237
+ "focusable": False,
238
+ "focused": False,
239
+ "selected": False,
240
+ "depth": 1,
241
+ "child_count": 0,
242
+ "children": [],
243
+ }
244
+ except Exception:
245
+ continue
246
+
247
+ root = {
248
+ "name": target.title or "",
249
+ "type": "Window",
250
+ "res_id": "",
251
+ "memory_id": root_hwnd,
252
+ "class_name": target.class_name or "",
253
+ "handle": root_hwnd,
254
+ "rect": {"left": 0, "top": 0, "right": client.width, "bottom": client.height},
255
+ "enabled": True,
256
+ "visible": target.is_visible,
257
+ "clickable": False,
258
+ "value": "",
259
+ "description": reason,
260
+ "password": False,
261
+ "checkable": False,
262
+ "checked": False,
263
+ "editable": False,
264
+ "focusable": False,
265
+ "focused": False,
266
+ "selected": False,
267
+ "depth": 0,
268
+ "child_count": 0,
269
+ "children": [],
270
+ }
271
+ for hwnd, element in elements.items():
272
+ parent_hwnd = win32gui.GetParent(hwnd)
273
+ while parent_hwnd not in elements and parent_hwnd != root_hwnd:
274
+ parent_hwnd = win32gui.GetParent(parent_hwnd)
275
+ if not parent_hwnd:
276
+ break
277
+ parent = elements.get(parent_hwnd, root)
278
+ element["depth"] = parent["depth"] + 1
279
+ parent["children"].append(element)
280
+ parent["child_count"] += 1
281
+ count = ToolApi._count_nodes(root)
282
+ if count == 1:
283
+ return {"status": "empty", "message": f"{reason};Win32 子窗口树也为空"}
284
+ return {
285
+ "status": "success", "backend": "Win32 HWND fallback",
286
+ "node_count": count, "message": reason, "data": root,
287
+ }
288
+
289
+ def capture(self, hwnd: Optional[int] = None) -> Dict[str, Any]:
290
+ try:
291
+ if hwnd:
292
+ image = Window(int(hwnd)).capture()
293
+ else:
294
+ image = Screen.capture()
295
+ if image is None:
296
+ return {"status": "error", "message": "截图失败"}
297
+ result = self._set_image(image)
298
+ if result.get("status") == "success":
299
+ result["cache_name"] = os.path.basename(self._cache_current_image("capture"))
300
+ return result
301
+ except Exception as exc:
302
+ return {"status": "error", "message": str(exc)}
303
+
304
+ def _set_image(self, image) -> Dict[str, Any]:
305
+ if image.mode != "RGB":
306
+ image = image.convert("RGB")
307
+ # Keep the lossless source for color algorithms. JPEG is only the
308
+ # transport/preview format and must not change sampled RGB values.
309
+ self._last_image = image.copy()
310
+ image_bytes = Screen.encode_image(image, "JPEG")
311
+ self._last_image_bytes = image_bytes
312
+ self._active_image_path = os.path.join(self._working_dir, "current.jpg")
313
+ with open(self._active_image_path, "wb") as active_file:
314
+ active_file.write(image_bytes)
315
+ encoded = base64.b64encode(image_bytes).decode("ascii")
316
+ return {
317
+ "status": "success",
318
+ "data": f"data:image/jpeg;base64,{encoded}",
319
+ "width": image.width,
320
+ "height": image.height,
321
+ "cache_path": self._active_image_path,
322
+ }
323
+
324
+ def get_pixel_color(self, x: int, y: int) -> Dict[str, Any]:
325
+ if self._last_image is None:
326
+ return {"status": "error", "message": "请先截图"}
327
+ x, y = int(x), int(y)
328
+ if x < 0 or y < 0 or x >= self._last_image.width or y >= self._last_image.height:
329
+ return {"status": "error", "message": "取色坐标超出图片范围"}
330
+ red, green, blue = self._last_image.getpixel((x, y))[:3]
331
+ return {
332
+ "status": "success",
333
+ "data": f"#{red:02X}{green:02X}{blue:02X}",
334
+ "x": x,
335
+ "y": y,
336
+ }
337
+
338
+ def load_image(self, data_url: str) -> Dict[str, Any]:
339
+ try:
340
+ if not isinstance(data_url, str) or not data_url.startswith("data:image/"):
341
+ return {"status": "error", "message": "请选择图片文件"}
342
+ header, encoded = data_url.split(",", 1)
343
+ if not header.endswith(";base64") or len(encoded) > 28_000_000:
344
+ return {"status": "error", "message": "图片格式不支持或文件过大(上限 20 MB)"}
345
+ from PIL import Image
346
+
347
+ with Image.open(io.BytesIO(base64.b64decode(encoded, validate=True))) as image:
348
+ if image.width * image.height > 80_000_000:
349
+ return {"status": "error", "message": "图片像素过大"}
350
+ image.load()
351
+ result = self._set_image(image)
352
+ if result.get("status") == "success":
353
+ result["cache_name"] = os.path.basename(self._cache_current_image("upload"))
354
+ return result
355
+ except Exception as exc:
356
+ return {"status": "error", "message": f"图片打开失败:{exc}"}
357
+
358
+ def test_selector(self, expression: str) -> Dict[str, Any]:
359
+ result = self._run_uia_worker(["test"], timeout=15, input_text=expression)
360
+ result.setdefault("data", [])
361
+ return result
362
+
363
+ def test_selector_spec(self, spec: Dict[str, Any]) -> Dict[str, Any]:
364
+ import json
365
+
366
+ result = self._run_uia_worker(
367
+ ["test-spec"], timeout=15,
368
+ input_text=json.dumps(spec, ensure_ascii=False, separators=(",", ":")),
369
+ )
370
+ result.setdefault("data", [])
371
+ return result
372
+
373
+ def find_colors(self, pattern: str, threshold: float = 0.95, max_results: int = 50,
374
+ rect=None, ori: int = 2, space: int = 5, mode: int = 0,
375
+ find_one: bool = False) -> Dict[str, Any]:
376
+ try:
377
+ if self._last_image is None:
378
+ return {"status": "error", "message": "请先截图", "data": []}
379
+ results = FindColors.find_all(
380
+ pattern,
381
+ rect=self._rect(rect),
382
+ threshold=float(threshold),
383
+ ori=int(ori),
384
+ exclusion_radius=max(0, int(space)),
385
+ im_source=self._last_image,
386
+ max_res=1 if find_one else max(1, min(int(max_results), 500)),
387
+ mode=int(mode),
388
+ )
389
+ return {"status": "success", "data": [{"x": x, "y": y} for x, y in results]}
390
+ except Exception as exc:
391
+ return {"status": "error", "message": str(exc), "data": []}
392
+
393
+ @staticmethod
394
+ def _rect(rect):
395
+ if not rect or len(rect) != 4:
396
+ return None
397
+ return [int(value) for value in rect]
398
+
399
+ @staticmethod
400
+ def _encode_cv_image(image) -> str:
401
+ import cv2
402
+
403
+ ok, encoded = cv2.imencode(".png", image)
404
+ if not ok:
405
+ raise RuntimeError("图像编码失败")
406
+ return "data:image/png;base64," + base64.b64encode(encoded.tobytes()).decode("ascii")
407
+
408
+ def _full_preview(self, output, rect=None):
409
+ """Composite a cropped algorithm preview back into capture coordinates."""
410
+ import cv2
411
+
412
+ source = AsCv.to_numpy(self._last_image).copy()
413
+ if output.ndim == 2:
414
+ output = cv2.cvtColor(output, cv2.COLOR_GRAY2BGR)
415
+ if rect is None:
416
+ return output
417
+ left, top, right, bottom = rect
418
+ target_width = max(1, right - left)
419
+ target_height = max(1, bottom - top)
420
+ if output.shape[1] != target_width or output.shape[0] != target_height:
421
+ output = cv2.resize(output, (target_width, target_height), interpolation=cv2.INTER_NEAREST)
422
+ source[top:bottom, left:right] = output
423
+ return source
424
+
425
+ def compare_colors(self, pattern: str, threshold: float = 0.95, required: float = 1.0,
426
+ mode: int = 0, tolerance=None) -> Dict[str, Any]:
427
+ try:
428
+ if self._last_image is None:
429
+ return {"status": "error", "message": "请先截图"}
430
+ diff = tolerance if tolerance is not None else float(threshold)
431
+ data = CompareColors.compare_detail(
432
+ pattern, diff=diff, sim=float(required), mode=int(mode), image=self._last_image
433
+ )
434
+ return {"status": "success", "data": data}
435
+ except Exception as exc:
436
+ return {"status": "error", "message": str(exc)}
437
+
438
+ def count_colors(self, colors: str, threshold: float = 0.95, rect=None,
439
+ draw_mode: int = Colors.DRAW_THRESHOLD, mode: int = 0) -> Dict[str, Any]:
440
+ try:
441
+ if self._last_image is None:
442
+ return {"status": "error", "message": "请先截图"}
443
+ search_rect = self._rect(rect)
444
+ output, count, offset = Colors.filter(
445
+ colors, rect=search_rect, sim=float(threshold), image=self._last_image,
446
+ draw_mode=int(draw_mode), mode=int(mode),
447
+ )
448
+ return {
449
+ "status": "success",
450
+ "data": {
451
+ "count": count,
452
+ "offset": list(offset),
453
+ "preview": self._encode_cv_image(self._full_preview(output, search_rect)),
454
+ },
455
+ }
456
+ except Exception as exc:
457
+ return {"status": "error", "message": str(exc)}
458
+
459
+ def find_blocks(self, colors: str, width: int = 50, height: int = 50, count: int = 100,
460
+ threshold: float = 0.9, rect=None, min_area: int = 10,
461
+ max_results: int = 100, mode: int = 0, space: int = 0) -> Dict[str, Any]:
462
+ try:
463
+ if self._last_image is None:
464
+ return {"status": "error", "message": "请先截图", "data": []}
465
+ search_rect = self._rect(rect)
466
+ points = FindBlock.find_all(
467
+ colors, int(width), int(height), int(count), search_rect, float(threshold),
468
+ mode=int(mode), space=int(space), image=self._last_image,
469
+ max_results=int(max_results),
470
+ )
471
+ ranges = FindBlock.get_range(
472
+ colors, search_rect, float(threshold), mode=int(mode),
473
+ min_area=int(min_area), image=self._last_image
474
+ )
475
+ return {
476
+ "status": "success",
477
+ "data": {
478
+ "points": [{"x": x, "y": y} for x, y in points],
479
+ "ranges": ranges,
480
+ "count": len(ranges),
481
+ },
482
+ }
483
+ except Exception as exc:
484
+ return {"status": "error", "message": str(exc), "data": []}
485
+
486
+ @staticmethod
487
+ def _safe_image_name(filename: str) -> str:
488
+ name = str(filename or "").strip()
489
+ if not name:
490
+ raise ValueError("请输入模板名称")
491
+ if os.path.basename(name) != name or not re.fullmatch(r"[^\\/:*?\"<>|\x00-\x1f]{1,120}", name):
492
+ raise ValueError("模板名称包含无效字符")
493
+ stem, extension = os.path.splitext(name)
494
+ if not stem:
495
+ raise ValueError("模板名称无效")
496
+ if extension.lower() != ".png":
497
+ name = stem + ".png"
498
+ return name
499
+
500
+ def _next_template_name(self) -> str:
501
+ used = {item["name"].casefold() for item in self._image_templates.values()}
502
+ index = 1
503
+ while f"{index}.png".casefold() in used:
504
+ index += 1
505
+ return f"{index}.png"
506
+
507
+ @staticmethod
508
+ def _template_payload(template_id: str, item: Dict[str, Any]) -> Dict[str, Any]:
509
+ return {
510
+ "id": template_id,
511
+ "name": item["name"],
512
+ "width": item["width"],
513
+ "height": item["height"],
514
+ "thumbnail": item["thumbnail"],
515
+ }
516
+
517
+ @staticmethod
518
+ def _template_image(image):
519
+ has_alpha = image.mode in {"RGBA", "LA"} or (
520
+ image.mode == "P" and "transparency" in image.info
521
+ )
522
+ return image.convert("RGBA" if has_alpha else "RGB")
523
+
524
+ @staticmethod
525
+ def _image_thumbnail(image, maximum=(240, 140)) -> str:
526
+ thumbnail = image.copy()
527
+ thumbnail.thumbnail(maximum)
528
+ preview = io.BytesIO()
529
+ thumbnail.save(preview, format="PNG")
530
+ return "data:image/png;base64," + base64.b64encode(preview.getvalue()).decode("ascii")
531
+
532
+ def _store_image_template(self, image, filename: str = "") -> Dict[str, Any]:
533
+ name = self._safe_image_name(filename or self._next_template_name())
534
+ if any(item["name"].casefold() == name.casefold()
535
+ for item in self._image_templates.values()):
536
+ return {"status": "error", "message": f"模板名称已存在: {name}"}
537
+ image = self._template_image(image)
538
+ template_id = uuid.uuid4().hex
539
+ path = os.path.join(self._template_dir, template_id + ".png")
540
+ image.save(path, format="PNG")
541
+ item = {
542
+ "name": name,
543
+ "path": path,
544
+ "width": image.width,
545
+ "height": image.height,
546
+ "thumbnail": self._image_thumbnail(image),
547
+ }
548
+ self._image_templates[template_id] = item
549
+ return {
550
+ "status": "success",
551
+ "data": self._template_payload(template_id, item),
552
+ "next_name": self._next_template_name(),
553
+ }
554
+
555
+ def _project_image_dir(self, create: bool = False, project_name=None) -> str:
556
+ project_root, _ = self._resolve_project_root(project_name)
557
+ destination = os.path.realpath(os.path.join(project_root, "res", "img"))
558
+ if os.path.commonpath([project_root, destination]) != project_root:
559
+ raise ValueError("工程资源目录无效")
560
+ if create:
561
+ os.makedirs(destination, exist_ok=True)
562
+ return destination
563
+
564
+ def list_image_templates(self) -> Dict[str, Any]:
565
+ return {
566
+ "status": "success",
567
+ "data": [self._template_payload(template_id, item)
568
+ for template_id, item in self._image_templates.items()],
569
+ "next_name": self._next_template_name(),
570
+ }
571
+
572
+ def add_image_template(self, rect, filename: str = "") -> Dict[str, Any]:
573
+ try:
574
+ if self._last_image is None:
575
+ return {"status": "error", "message": "请先截图"}
576
+ crop_rect = self._rect(rect)
577
+ if crop_rect is None:
578
+ return {"status": "error", "message": "请先框选要查找的子图"}
579
+ left, top, right, bottom = crop_rect
580
+ left = max(0, min(left, self._last_image.width))
581
+ right = max(0, min(right, self._last_image.width))
582
+ top = max(0, min(top, self._last_image.height))
583
+ bottom = max(0, min(bottom, self._last_image.height))
584
+ if right <= left or bottom <= top:
585
+ return {"status": "error", "message": "模板选区为空"}
586
+ image = self._last_image.crop((left, top, right, bottom))
587
+ return self._store_image_template(image, filename)
588
+ except Exception as exc:
589
+ return {"status": "error", "message": str(exc)}
590
+
591
+ def import_image_template(self, data_url: str, filename: str = "") -> Dict[str, Any]:
592
+ try:
593
+ header, encoded = str(data_url).split(",", 1)
594
+ if not header.startswith("data:image/") or not header.endswith(";base64"):
595
+ return {"status": "error", "message": "小图格式不支持"}
596
+ if len(encoded) > 28_000_000:
597
+ return {"status": "error", "message": "小图文件过大(上限 20 MB)"}
598
+ from PIL import Image
599
+
600
+ with Image.open(io.BytesIO(base64.b64decode(encoded, validate=True))) as image:
601
+ if image.width * image.height > 80_000_000:
602
+ return {"status": "error", "message": "小图像素过大"}
603
+ image.load()
604
+ return self._store_image_template(image, filename)
605
+ except Exception as exc:
606
+ return {"status": "error", "message": f"小图导入失败:{exc}"}
607
+
608
+ def list_project_images(self) -> Dict[str, Any]:
609
+ try:
610
+ root = self._project_image_dir()
611
+ except ValueError as exc:
612
+ return {"status": "success", "data": [], "available": False, "message": str(exc)}
613
+ if not os.path.isdir(root):
614
+ return {"status": "success", "data": [], "available": True}
615
+ items = []
616
+ from PIL import Image
617
+
618
+ for name in sorted(os.listdir(root), key=str.casefold):
619
+ if not name.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".webp")):
620
+ continue
621
+ path = os.path.realpath(os.path.join(root, name))
622
+ if os.path.commonpath([root, path]) != root or not os.path.isfile(path):
623
+ continue
624
+ try:
625
+ with Image.open(path) as image:
626
+ image.load()
627
+ items.append({
628
+ "name": name,
629
+ "width": image.width,
630
+ "height": image.height,
631
+ "thumbnail": self._image_thumbnail(self._template_image(image), (120, 80)),
632
+ })
633
+ except (OSError, ValueError):
634
+ continue
635
+ return {"status": "success", "data": items, "available": True}
636
+
637
+ def add_project_image_template(self, filename: str) -> Dict[str, Any]:
638
+ try:
639
+ root = self._project_image_dir()
640
+ name = os.path.basename(str(filename))
641
+ if name != filename or not name.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".webp")):
642
+ return {"status": "error", "message": "工程图片名称无效"}
643
+ path = os.path.realpath(os.path.join(root, name))
644
+ if os.path.commonpath([root, path]) != root or not os.path.isfile(path):
645
+ return {"status": "error", "message": f"工程图片不存在: {name}"}
646
+ from PIL import Image
647
+
648
+ with Image.open(path) as image:
649
+ image.load()
650
+ return self._store_image_template(image, name)
651
+ except Exception as exc:
652
+ return {"status": "error", "message": str(exc)}
653
+
654
+ def clear_image_templates(self) -> Dict[str, Any]:
655
+ removed = len(self._image_templates)
656
+ for item in self._image_templates.values():
657
+ try:
658
+ os.remove(item["path"])
659
+ except FileNotFoundError:
660
+ pass
661
+ self._image_templates.clear()
662
+ return {"status": "success", "removed": removed, "next_name": self._next_template_name()}
663
+
664
+ def rename_image_template(self, template_id: str, filename: str) -> Dict[str, Any]:
665
+ try:
666
+ item = self._image_templates.get(str(template_id))
667
+ if item is None:
668
+ return {"status": "error", "message": "模板不存在"}
669
+ name = self._safe_image_name(filename)
670
+ if any(key != template_id and value["name"].casefold() == name.casefold()
671
+ for key, value in self._image_templates.items()):
672
+ return {"status": "error", "message": f"模板名称已存在: {name}"}
673
+ item["name"] = name
674
+ return {"status": "success", "data": self._template_payload(template_id, item)}
675
+ except Exception as exc:
676
+ return {"status": "error", "message": str(exc)}
677
+
678
+ def delete_image_template(self, template_id: str) -> Dict[str, Any]:
679
+ item = self._image_templates.pop(str(template_id), None)
680
+ if item is None:
681
+ return {"status": "error", "message": "模板不存在"}
682
+ try:
683
+ os.remove(item["path"])
684
+ except FileNotFoundError:
685
+ pass
686
+ return {"status": "success", "next_name": self._next_template_name()}
687
+
688
+ def save_image_templates(self, project_name=None, overwrite: bool = False) -> Dict[str, Any]:
689
+ try:
690
+ destination = self._project_image_dir(create=True, project_name=project_name)
691
+ conflicts = [item["name"] for item in self._image_templates.values()
692
+ if os.path.exists(os.path.join(destination, item["name"]))]
693
+ if conflicts and not overwrite:
694
+ return {"status": "conflict", "message": "工程中已存在同名图片", "conflicts": conflicts}
695
+ saved = []
696
+ for item in self._image_templates.values():
697
+ target = os.path.join(destination, item["name"])
698
+ shutil.copyfile(item["path"], target)
699
+ saved.append(target)
700
+ return {"status": "success", "data": saved, "project": str(project_name or self._project_name)}
701
+ except Exception as exc:
702
+ return {"status": "error", "message": str(exc)}
703
+
704
+ def find_images(self, template_ids, search_rect=None, confidence: float = 0.9,
705
+ max_results: int = 50, algorithm: str = "template",
706
+ rgb: bool = False, find_one: bool = False, ori: int = 2,
707
+ scale_range=None) -> Dict[str, Any]:
708
+ try:
709
+ if self._last_image is None:
710
+ return {"status": "error", "message": "请先截图", "data": []}
711
+ ids = [str(value) for value in (template_ids or [])]
712
+ templates = [self._image_templates[value]["path"] for value in ids
713
+ if value in self._image_templates]
714
+ if not templates:
715
+ return {"status": "error", "message": "请先添加要查找的子图", "data": []}
716
+ method = str(algorithm or "template").lower()
717
+ function = {
718
+ "template": FindImages.find_template if find_one else FindImages.find_all_template,
719
+ "sift": FindImages.find_sift if find_one else FindImages.find_all_sift,
720
+ "mixed": FindImages.find if find_one else FindImages.find_all,
721
+ }.get(method)
722
+ if function is None:
723
+ return {"status": "error", "message": f"不支持的找图模式: {algorithm}", "data": []}
724
+ kwargs = {
725
+ "rect": self._rect(search_rect),
726
+ "confidence": float(confidence),
727
+ "rgb": bool(rgb),
728
+ "source_img": self._last_image,
729
+ "ori": int(ori),
730
+ "scale_range": scale_range,
731
+ }
732
+ if not find_one:
733
+ kwargs["maxcnt"] = max(1, min(int(max_results), 500))
734
+ matches = function(templates, **kwargs)
735
+ if find_one:
736
+ matches = [matches] if matches is not None else []
737
+ data = [match.to_dict() if hasattr(match, "to_dict") else dict(match) for match in matches]
738
+ return {"status": "success", "data": data}
739
+ except Exception as exc:
740
+ return {"status": "error", "message": str(exc), "data": []}
741
+
742
+ def run_ocr(self, rect=None, confidence: float = 0.1, pattern: str = "", binary: int = -1,
743
+ operation: str = "recognize") -> Dict[str, Any]:
744
+ try:
745
+ if self._last_image is None:
746
+ return {"status": "error", "message": "请先截图", "data": []}
747
+ kwargs = {
748
+ "rect": self._rect(rect), "confidence": float(confidence),
749
+ "image": self._last_image, "binary": int(binary),
750
+ }
751
+ if operation == "recognize":
752
+ data = [item.to_dict() for item in Ocr.recognize(pattern=pattern or None, **kwargs)]
753
+ elif operation == "find_all":
754
+ items = Ocr.find_all(text=pattern or None, **kwargs)
755
+ data = [item.to_dict() for item in items] or None
756
+ elif operation == "find":
757
+ item = Ocr.find(text=pattern or None, **kwargs)
758
+ data = item.to_dict() if item else None
759
+ elif operation == "exists":
760
+ data = Ocr.exists(text=pattern or None, **kwargs)
761
+ else:
762
+ raise ValueError(f"不支持的 OCR 操作: {operation}")
763
+ return {
764
+ "status": "success", "backend": Ocr.backend_info(),
765
+ "data": data,
766
+ }
767
+ except Exception as exc:
768
+ return {"status": "error", "message": str(exc), "data": []}
769
+
770
+ def run_ocrx(self, font_lib: str, rect=None, confidence: float = 0.9) -> Dict[str, Any]:
771
+ try:
772
+ if self._last_image is None:
773
+ return {"status": "error", "message": "请先截图", "data": []}
774
+ result = OcrX.recognize(
775
+ font_lib, rect=self._rect(rect), region=float(confidence), image=self._last_image
776
+ )
777
+ return {"status": "success", "data": result.to_dict()}
778
+ except Exception as exc:
779
+ return {"status": "error", "message": str(exc), "data": []}
780
+
781
+ def run_canny(self, rect=None, low: int = 50, high: int = 150, algorithm: str = "canny",
782
+ min_area: int = 10) -> Dict[str, Any]:
783
+ try:
784
+ if self._last_image is None:
785
+ return {"status": "error", "message": "请先截图", "data": []}
786
+ search_rect = self._rect(rect)
787
+ results, output, edges = Canny.detect(
788
+ int(low), int(high), image=self._last_image, rect=search_rect,
789
+ algo=algorithm, area_range=[int(min_area), 0], draw=Canny.DRAW_RECT,
790
+ return_image=True,
791
+ )
792
+ return {
793
+ "status": "success",
794
+ "data": {
795
+ "items": results,
796
+ "preview": self._encode_cv_image(self._full_preview(output, search_rect)),
797
+ "edges": self._encode_cv_image(edges),
798
+ },
799
+ }
800
+ except Exception as exc:
801
+ return {"status": "error", "message": str(exc), "data": []}
802
+
803
+ def scan_codes(self, rect=None) -> Dict[str, Any]:
804
+ try:
805
+ if self._last_image is None:
806
+ return {"status": "error", "message": "请先截图", "data": []}
807
+ return {
808
+ "status": "success",
809
+ "data": CodeScanner.scan(rect=self._rect(rect), image=self._last_image),
810
+ }
811
+ except Exception as exc:
812
+ return {"status": "error", "message": str(exc), "data": []}
813
+
814
+ def save_capture(self, filename: str = "capture.png") -> Dict[str, Any]:
815
+ if self._last_image is None:
816
+ return {"status": "error", "message": "没有可保存的截图"}
817
+ safe_name = os.path.basename(filename) or "capture.png"
818
+ if not safe_name.lower().endswith((".png", ".jpg", ".jpeg")):
819
+ safe_name += ".png"
820
+ path = os.path.join(self._capture_dir, safe_name)
821
+ self._last_image.save(path)
822
+ return {"status": "success", "path": path}
823
+
824
+ def _cache_current_image(self, source: str) -> str:
825
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
826
+ path = os.path.join(self._capture_dir, f"{source}-{stamp}.jpg")
827
+ with open(path, "wb") as cache_file:
828
+ cache_file.write(self._last_image_bytes)
829
+ return path
830
+
831
+ def list_capture_cache(self) -> Dict[str, Any]:
832
+ items = []
833
+ for name in os.listdir(self._capture_dir):
834
+ path = os.path.join(self._capture_dir, name)
835
+ if not os.path.isfile(path) or not name.lower().endswith((".png", ".jpg", ".jpeg")):
836
+ continue
837
+ try:
838
+ stat = os.stat(path)
839
+ from PIL import Image
840
+
841
+ with Image.open(path) as image:
842
+ width, height = image.size
843
+ thumbnail = image.copy()
844
+ thumbnail.thumbnail((150, 90))
845
+ preview = io.BytesIO()
846
+ thumbnail.save(preview, format="JPEG", quality=75)
847
+ items.append({
848
+ "name": name,
849
+ "path": path,
850
+ "width": width,
851
+ "height": height,
852
+ "size": stat.st_size,
853
+ "modified": stat.st_mtime,
854
+ "thumbnail": "data:image/jpeg;base64," + base64.b64encode(preview.getvalue()).decode("ascii"),
855
+ })
856
+ except (OSError, ValueError):
857
+ continue
858
+ items.sort(key=lambda item: item["modified"], reverse=True)
859
+ return {"status": "success", "data": items}
860
+
861
+ def load_cached_capture(self, name: str) -> Dict[str, Any]:
862
+ try:
863
+ safe_name = os.path.basename(str(name))
864
+ if safe_name != name or not safe_name.lower().endswith((".png", ".jpg", ".jpeg")):
865
+ return {"status": "error", "message": "缓存图片名称无效"}
866
+ root = os.path.realpath(self._capture_dir)
867
+ path = os.path.realpath(os.path.join(root, safe_name))
868
+ if os.path.commonpath([root, path]) != root:
869
+ return {"status": "error", "message": "缓存图片路径无效"}
870
+ from PIL import Image
871
+
872
+ with Image.open(path) as image:
873
+ image.load()
874
+ result = self._set_image(image)
875
+ result["cache_name"] = safe_name
876
+ return result
877
+ except Exception as exc:
878
+ return {"status": "error", "message": f"缓存图片读取失败:{exc}"}
879
+
880
+ def delete_capture_cache(self, name: str) -> Dict[str, Any]:
881
+ safe_name = os.path.basename(str(name))
882
+ if safe_name != name or not safe_name.lower().endswith((".png", ".jpg", ".jpeg")):
883
+ return {"status": "error", "message": "缓存图片名称无效"}
884
+ root = os.path.realpath(self._capture_dir)
885
+ path = os.path.realpath(os.path.join(root, safe_name))
886
+ if os.path.commonpath([root, path]) != root:
887
+ return {"status": "error", "message": "缓存图片路径无效"}
888
+ try:
889
+ os.remove(path)
890
+ return {"status": "success"}
891
+ except Exception as exc:
892
+ return {"status": "error", "message": f"删除缓存失败:{exc}"}
893
+
894
+ def clear_capture_cache(self) -> Dict[str, Any]:
895
+ removed = 0
896
+ for item in self.list_capture_cache().get("data", []):
897
+ try:
898
+ os.remove(item["path"])
899
+ removed += 1
900
+ except OSError:
901
+ pass
902
+ return {"status": "success", "removed": removed}
903
+
904
+ def open_capture_folder(self) -> bool:
905
+ subprocess.Popen(["explorer.exe", self._capture_dir])
906
+ return True