VirtualDesktop 1.0.1__py3-none-any.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.
@@ -0,0 +1,671 @@
1
+ """Capture engine: ``PrintWindow`` + ``PW_RENDERFULLCONTENT`` with real fallbacks.
2
+
3
+ Offscreen capture is not one API call, it is a decision tree, because no single Win32
4
+ method works on every window class:
5
+
6
+ =============== =====================================================================
7
+ method behaviour
8
+ =============== =====================================================================
9
+ printwindow ``PrintWindow(hwnd, dc, PW_RENDERFULLCONTENT)``. Best result for most
10
+ Win32/WPF windows, *including completely offscreen ones* (the window
11
+ paints itself into our DC). Fails or returns black for some
12
+ DirectComposition/DirectX surfaces.
13
+ printwindow_plain ``PW_RENDERFULLCONTENT`` was added in Windows 8.1; older systems or
14
+ non-composited windows need the plain variant.
15
+ wm_print ``WM_PRINT``/``WM_PRINTCLIENT`` via ``SendMessageTimeout``. Slow, but it
16
+ works on a few legacy controls that ignore ``PrintWindow``.
17
+ bitblt ``BitBlt(SRCCOPY)`` from the window DC. Content-accurate for GDI windows
18
+ **only while the window is actually rendered**, so it is the last resort
19
+ for offscreen mounts.
20
+ =============== =====================================================================
21
+
22
+ Every result is validated (see :func:`analyze_image`): a uniform, all-black or all-white
23
+ frame is treated as a *failure*, not as a screenshot, and the engine moves on to the next
24
+ method. That check is what turns "sometimes I get a black PNG" into a deterministic
25
+ fallback chain.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import ctypes
31
+ import time
32
+ from ctypes import wintypes
33
+ from dataclasses import dataclass, field
34
+ from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple
35
+
36
+ from ._log import get_logger
37
+ from ._winapi import (
38
+ IS_WINDOWS,
39
+ dpi_awareness_scope,
40
+ is_owned_window,
41
+ require_windows,
42
+ )
43
+ from .errors import CaptureError
44
+
45
+ log = get_logger("capture")
46
+
47
+ __all__ = [
48
+ "DEFAULT_METHODS",
49
+ "Frame",
50
+ "ImageAnalysis",
51
+ "analyze_image",
52
+ "CaptureEngine",
53
+ ]
54
+
55
+ DEFAULT_METHODS: Tuple[str, ...] = ("printwindow", "printwindow_plain", "wm_print", "bitblt")
56
+
57
+ if IS_WINDOWS: # pragma: no branch
58
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
59
+ gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
60
+ try:
61
+ dwmapi = ctypes.WinDLL("dwmapi", use_last_error=True)
62
+ except OSError: # pragma: no cover
63
+ dwmapi = None
64
+ else: # pragma: no cover
65
+ user32 = gdi32 = dwmapi = None
66
+
67
+ PW_CLIENTONLY = 0x00000001
68
+ PW_RENDERFULLCONTENT = 0x00000002
69
+ BI_RGB = 0
70
+ DIB_RGB_COLORS = 0
71
+ SRCCOPY = 0x00CC0020
72
+ CAPTUREBLT = 0x40000000
73
+ WM_PRINT = 0x0317
74
+ WM_PRINTCLIENT = 0x0318
75
+ PRF_CLIENT = 0x00000004
76
+ PRF_CHILDREN = 0x00000010
77
+ PRF_ERASEBKGND = 0x00000008
78
+ PRF_NONCLIENT = 0x00000002
79
+ SMTO_ABORTIFHUNG = 0x0002
80
+ _GA_PARENT = 1
81
+
82
+
83
+ class _RECT(ctypes.Structure):
84
+ _fields_ = [
85
+ ("left", wintypes.LONG),
86
+ ("top", wintypes.LONG),
87
+ ("right", wintypes.LONG),
88
+ ("bottom", wintypes.LONG),
89
+ ]
90
+
91
+
92
+ class _POINT(ctypes.Structure):
93
+ _fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
94
+
95
+
96
+ class _BITMAPINFOHEADER(ctypes.Structure):
97
+ _fields_ = [
98
+ ("biSize", wintypes.DWORD),
99
+ ("biWidth", wintypes.LONG),
100
+ ("biHeight", wintypes.LONG),
101
+ ("biPlanes", wintypes.WORD),
102
+ ("biBitCount", wintypes.WORD),
103
+ ("biCompression", wintypes.DWORD),
104
+ ("biSizeImage", wintypes.DWORD),
105
+ ("biXPelsPerMeter", wintypes.LONG),
106
+ ("biYPelsPerMeter", wintypes.LONG),
107
+ ("biClrUsed", wintypes.DWORD),
108
+ ("biClrImportant", wintypes.DWORD),
109
+ ]
110
+
111
+
112
+ if IS_WINDOWS: # pragma: no branch
113
+ user32.PrintWindow.argtypes = [wintypes.HWND, wintypes.HDC, wintypes.UINT]
114
+ user32.PrintWindow.restype = wintypes.BOOL
115
+ user32.GetWindowDC.argtypes = [wintypes.HWND]
116
+ user32.GetWindowDC.restype = wintypes.HDC
117
+ user32.GetDC.argtypes = [wintypes.HWND]
118
+ user32.GetDC.restype = wintypes.HDC
119
+ user32.ReleaseDC.argtypes = [wintypes.HWND, wintypes.HDC]
120
+ user32.ReleaseDC.restype = ctypes.c_int
121
+ user32.GetClientRect.argtypes = [wintypes.HWND, ctypes.POINTER(_RECT)]
122
+ user32.GetClientRect.restype = wintypes.BOOL
123
+ user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(_RECT)]
124
+ user32.GetWindowRect.restype = wintypes.BOOL
125
+ user32.ClientToScreen.argtypes = [wintypes.HWND, ctypes.POINTER(_POINT)]
126
+ user32.ClientToScreen.restype = wintypes.BOOL
127
+ user32.IsWindow.argtypes = [wintypes.HWND]
128
+ user32.IsWindow.restype = wintypes.BOOL
129
+ user32.IsWindowVisible.argtypes = [wintypes.HWND]
130
+ user32.IsWindowVisible.restype = wintypes.BOOL
131
+ user32.IsIconic.argtypes = [wintypes.HWND]
132
+ user32.IsIconic.restype = wintypes.BOOL
133
+ user32.SendMessageTimeoutW.argtypes = [
134
+ wintypes.HWND,
135
+ wintypes.UINT,
136
+ wintypes.WPARAM,
137
+ wintypes.LPARAM,
138
+ wintypes.UINT,
139
+ wintypes.UINT,
140
+ ctypes.POINTER(ctypes.c_void_p),
141
+ ]
142
+ user32.SendMessageTimeoutW.restype = wintypes.LPARAM
143
+ gdi32.CreateCompatibleDC.argtypes = [wintypes.HDC]
144
+ gdi32.CreateCompatibleDC.restype = wintypes.HDC
145
+ gdi32.CreateCompatibleBitmap.argtypes = [wintypes.HDC, ctypes.c_int, ctypes.c_int]
146
+ gdi32.CreateCompatibleBitmap.restype = wintypes.HBITMAP
147
+ gdi32.CreateDIBSection.argtypes = [
148
+ wintypes.HDC,
149
+ ctypes.POINTER(_BITMAPINFOHEADER),
150
+ wintypes.UINT,
151
+ ctypes.POINTER(ctypes.c_void_p),
152
+ wintypes.HANDLE,
153
+ wintypes.DWORD,
154
+ ]
155
+ gdi32.CreateDIBSection.restype = wintypes.HBITMAP
156
+ gdi32.SelectObject.argtypes = [wintypes.HDC, wintypes.HGDIOBJ]
157
+ gdi32.SelectObject.restype = wintypes.HGDIOBJ
158
+ gdi32.DeleteObject.argtypes = [wintypes.HGDIOBJ]
159
+ gdi32.DeleteObject.restype = wintypes.BOOL
160
+ gdi32.DeleteDC.argtypes = [wintypes.HDC]
161
+ gdi32.DeleteDC.restype = wintypes.BOOL
162
+ gdi32.BitBlt.argtypes = [
163
+ wintypes.HDC,
164
+ ctypes.c_int,
165
+ ctypes.c_int,
166
+ ctypes.c_int,
167
+ ctypes.c_int,
168
+ wintypes.HDC,
169
+ ctypes.c_int,
170
+ ctypes.c_int,
171
+ wintypes.DWORD,
172
+ ]
173
+ gdi32.BitBlt.restype = wintypes.BOOL
174
+ gdi32.GetDIBits.argtypes = [
175
+ wintypes.HDC,
176
+ wintypes.HBITMAP,
177
+ wintypes.UINT,
178
+ wintypes.UINT,
179
+ ctypes.c_void_p,
180
+ ctypes.POINTER(_BITMAPINFOHEADER),
181
+ wintypes.UINT,
182
+ ]
183
+ gdi32.GetDIBits.restype = ctypes.c_int
184
+
185
+
186
+ @dataclass
187
+ class ImageAnalysis:
188
+ """Cheap statistical validation of a captured frame."""
189
+
190
+ width: int = 0
191
+ height: int = 0
192
+ variance: float = 0.0
193
+ mean: float = 0.0
194
+ max_value: int = 0
195
+ dominant_ratio: float = 0.0
196
+ uniform: bool = False
197
+ blank: bool = False
198
+ reason: str = ""
199
+
200
+ @property
201
+ def usable(self) -> bool:
202
+ return not self.blank
203
+
204
+ def to_dict(self) -> dict:
205
+ return {
206
+ "width": self.width,
207
+ "height": self.height,
208
+ "variance": round(self.variance, 4),
209
+ "mean": round(self.mean, 3),
210
+ "max_value": self.max_value,
211
+ "dominant_ratio": round(self.dominant_ratio, 4),
212
+ "uniform": self.uniform,
213
+ "blank": self.blank,
214
+ "reason": self.reason,
215
+ }
216
+
217
+
218
+ def analyze_image(
219
+ image,
220
+ *,
221
+ variance_threshold: float = 0.05,
222
+ dominant_threshold: float = 0.999,
223
+ min_max_value: int = 10,
224
+ ) -> ImageAnalysis:
225
+ """Decide whether a frame carries real content.
226
+
227
+ Three failure signatures are checked, because no single one is sufficient:
228
+
229
+ * **near-black frame** -- ``max(pixel) < min_max_value``. This is the cheap, very reliable
230
+ check used by production WeChat automation (``np.array(img).max() > 10``): any real
231
+ window contains at least one bright pixel, a failed ``PrintWindow`` does not.
232
+ * **uniform surface** -- variance below ``variance_threshold``: nothing was painted at all
233
+ (covers all-white as well as a solid theme colour).
234
+ * **single colour** -- one colour covering at least ``dominant_threshold`` of the pixels
235
+ *and* still lacking structure (variance < 1.0). The variance guard keeps a mostly-white
236
+ window with a little text acceptable.
237
+ """
238
+ try:
239
+ from PIL import Image, ImageStat
240
+ except ImportError as exc: # pragma: no cover - Pillow is a hard dependency
241
+ raise CaptureError(
242
+ f"Pillow is required for capture validation: {exc}", code="missing-pillow"
243
+ ) from exc
244
+
245
+ analysis = ImageAnalysis(width=image.width, height=image.height)
246
+ if image.width == 0 or image.height == 0:
247
+ analysis.blank = True
248
+ analysis.reason = "empty-geometry"
249
+ return analysis
250
+ greyscale = image.convert("L")
251
+ stats = ImageStat.Stat(greyscale)
252
+ analysis.mean = float(stats.mean[0])
253
+ analysis.variance = float(stats.var[0])
254
+ try:
255
+ analysis.max_value = int(stats.extrema[0][1])
256
+ except Exception: # pragma: no cover - defensive
257
+ analysis.max_value = 255
258
+ try:
259
+ channels = image.split()[:3]
260
+ peak = 0
261
+ total = 0
262
+ for channel in channels:
263
+ histogram = channel.histogram()
264
+ peak = max(peak, max(histogram))
265
+ total = max(total, sum(histogram))
266
+ analysis.dominant_ratio = (peak / total) if total else 0.0
267
+ except Exception: # pragma: no cover - defensive
268
+ analysis.dominant_ratio = 0.0
269
+
270
+ if analysis.variance < variance_threshold:
271
+ analysis.uniform = True
272
+ analysis.blank = True
273
+ analysis.reason = f"uniform(variance={analysis.variance:.4f})"
274
+ return analysis
275
+ if analysis.max_value < min_max_value:
276
+ # Cheap, very reliable failure signature used by production WeChat automation
277
+ # (``np.array(img).max() > 10``): a real window always contains a bright pixel.
278
+ analysis.blank = True
279
+ analysis.reason = f"near-black(max={analysis.max_value})"
280
+ return analysis
281
+ if analysis.dominant_ratio >= dominant_threshold and analysis.variance < 1.0:
282
+ # A single colour across (almost) the whole frame with no structure.
283
+ analysis.blank = True
284
+ analysis.reason = f"single-colour({analysis.dominant_ratio:.4f})"
285
+ return analysis
286
+ return analysis
287
+
288
+
289
+ @dataclass
290
+ class Frame:
291
+ """A captured frame plus everything needed to explain how it was produced."""
292
+
293
+ image: object # PIL.Image.Image (kept untyped to avoid a hard import at module load)
294
+ hwnd: int = 0
295
+ method: str = ""
296
+ region: Tuple[int, int, int, int] = (0, 0, 0, 0)
297
+ client_only: bool = True
298
+ dpi_aware: bool = False
299
+ attempts: List[dict] = field(default_factory=list)
300
+ analysis: Optional[ImageAnalysis] = None
301
+ duration: float = 0.0
302
+
303
+ @property
304
+ def size(self) -> Tuple[int, int]:
305
+ return (self.image.width, self.image.height) # type: ignore[attr-defined]
306
+
307
+ def save(self, path, **kwargs) -> str:
308
+ """Save the frame (PNG by default) and return the written path."""
309
+ target = str(path)
310
+ self.image.save(target, **kwargs) # type: ignore[attr-defined]
311
+ return target
312
+
313
+ def to_dict(self) -> dict:
314
+ return {
315
+ "hwnd": self.hwnd,
316
+ "method": self.method,
317
+ "region": list(self.region),
318
+ "client_only": self.client_only,
319
+ "dpi_aware": self.dpi_aware,
320
+ "size": list(self.size),
321
+ "attempts": list(self.attempts),
322
+ "analysis": self.analysis.to_dict() if self.analysis else None,
323
+ "duration": round(self.duration, 4),
324
+ }
325
+
326
+
327
+ def _virtual_screen() -> Tuple[int, int, int, int]:
328
+ SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 76, 77, 78, 79
329
+ return (
330
+ user32.GetSystemMetrics(SM_XVIRTUALSCREEN),
331
+ user32.GetSystemMetrics(SM_YVIRTUALSCREEN),
332
+ user32.GetSystemMetrics(SM_CXVIRTUALSCREEN),
333
+ user32.GetSystemMetrics(SM_CYVIRTUALSCREEN),
334
+ )
335
+
336
+
337
+ class _MemoryBitmap:
338
+ """A top-down 32-bit DIB section plus its DC; always released."""
339
+
340
+ def __init__(self, width: int, height: int):
341
+ self.width = int(width)
342
+ self.height = int(height)
343
+ self._screen_dc = user32.GetDC(None)
344
+ if not self._screen_dc:
345
+ raise CaptureError(
346
+ f"GetDC(NULL) failed with Win32 error {ctypes.get_last_error()}",
347
+ code="getdc-failed",
348
+ )
349
+ self.dc = gdi32.CreateCompatibleDC(self._screen_dc)
350
+ if not self.dc:
351
+ user32.ReleaseDC(None, self._screen_dc)
352
+ raise CaptureError("CreateCompatibleDC failed", code="createdc-failed")
353
+ header = _BITMAPINFOHEADER()
354
+ header.biSize = ctypes.sizeof(_BITMAPINFOHEADER)
355
+ header.biWidth = self.width
356
+ header.biHeight = -self.height # negative => top-down rows
357
+ header.biPlanes = 1
358
+ header.biBitCount = 32
359
+ header.biCompression = BI_RGB
360
+ self.bits = ctypes.c_void_p()
361
+ self._header = header
362
+ self.bitmap = gdi32.CreateDIBSection(
363
+ self.dc, ctypes.byref(header), DIB_RGB_COLORS, ctypes.byref(self.bits), None, 0
364
+ )
365
+ if not self.bitmap:
366
+ self.close()
367
+ raise CaptureError("CreateDIBSection failed", code="createdib-failed")
368
+ self._previous = gdi32.SelectObject(self.dc, self.bitmap)
369
+
370
+ def buffer(self) -> ctypes.Array:
371
+ size = self.width * self.height * 4
372
+ return ctypes.cast(self.bits, ctypes.POINTER(ctypes.c_char * size)).contents
373
+
374
+ def to_image(self):
375
+ from PIL import Image
376
+
377
+ data = bytes(self.buffer())
378
+ return Image.frombuffer("RGB", (self.width, self.height), data, "raw", "BGRX", 0, 1)
379
+
380
+ def close(self) -> None:
381
+ if getattr(self, "bitmap", None):
382
+ if getattr(self, "_previous", None):
383
+ gdi32.SelectObject(self.dc, self._previous)
384
+ self._previous = None
385
+ gdi32.DeleteObject(self.bitmap)
386
+ self.bitmap = None
387
+ if getattr(self, "dc", None):
388
+ gdi32.DeleteDC(self.dc)
389
+ self.dc = None
390
+ if getattr(self, "_screen_dc", None):
391
+ user32.ReleaseDC(None, self._screen_dc)
392
+ self._screen_dc = None
393
+
394
+ def __enter__(self) -> "_MemoryBitmap":
395
+ return self
396
+
397
+ def __exit__(self, exc_type, exc, tb) -> None:
398
+ self.close()
399
+
400
+
401
+ class CaptureEngine:
402
+ """Runs the capture decision tree and returns the first *usable* frame."""
403
+
404
+ def __init__(
405
+ self,
406
+ *,
407
+ methods: Sequence[str] = DEFAULT_METHODS,
408
+ client_only: bool = True,
409
+ validate: bool = True,
410
+ variance_threshold: float = 0.05,
411
+ dominant_threshold: float = 0.999,
412
+ timeout: float = 5.0,
413
+ allow_blank_fallback: bool = True,
414
+ ) -> None:
415
+ unknown = [m for m in methods if m not in DEFAULT_METHODS]
416
+ if unknown:
417
+ raise CaptureError(
418
+ f"unknown capture method(s): {', '.join(unknown)}",
419
+ code="unknown-method",
420
+ hint=f"Available methods: {', '.join(DEFAULT_METHODS)}",
421
+ )
422
+ self.methods = tuple(methods)
423
+ self.client_only = client_only
424
+ self.validate = validate
425
+ self.variance_threshold = variance_threshold
426
+ self.dominant_threshold = dominant_threshold
427
+ self.timeout = timeout
428
+ self.allow_blank_fallback = allow_blank_fallback
429
+ self._dpi_entered = False
430
+
431
+ # -- public API ------------------------------------------------------------------
432
+ def capture(
433
+ self,
434
+ hwnd: int,
435
+ *,
436
+ client_only: Optional[bool] = None,
437
+ methods: Optional[Sequence[str]] = None,
438
+ region: Optional[Tuple[int, int, int, int]] = None,
439
+ require_content: Optional[bool] = None,
440
+ save_to: Optional[str] = None,
441
+ ) -> Frame:
442
+ """Capture ``hwnd``.
443
+
444
+ ``region`` (left, top, right, bottom in *client* coordinates) crops the result,
445
+ which is what OCR pipelines usually want. ``save_to`` writes the frame to disk
446
+ (PNG by default) before returning it.
447
+ """
448
+ require_windows("CaptureEngine.capture")
449
+ if not user32.IsWindow(hwnd):
450
+ raise CaptureError(
451
+ f"0x{hwnd:X} is not a window (it may have been closed)", code="invalid-hwnd"
452
+ )
453
+ use_client = self.client_only if client_only is None else client_only
454
+ chain = tuple(methods or self.methods)
455
+ must_have_content = self.validate if require_content is None else require_content
456
+ started = time.monotonic()
457
+ attempts: List[dict] = []
458
+ blank_frame: Optional[Frame] = None
459
+
460
+ self.ensure_dpi_aware()
461
+ for method in chain:
462
+ try:
463
+ image, used_region = self._invoke(method, hwnd, use_client)
464
+ except CaptureError as exc:
465
+ attempts.append(
466
+ {"method": method, "ok": False, "error": str(exc), "code": exc.code}
467
+ )
468
+ log.debug("capture method %s failed: %s", method, exc)
469
+ continue
470
+ analysis = (
471
+ analyze_image(
472
+ image,
473
+ variance_threshold=self.variance_threshold,
474
+ dominant_threshold=self.dominant_threshold,
475
+ )
476
+ if self.validate
477
+ else None
478
+ )
479
+ attempts.append(
480
+ {
481
+ "method": method,
482
+ "ok": True,
483
+ "analysis": analysis.to_dict() if analysis else None,
484
+ }
485
+ )
486
+ frame = Frame(
487
+ image=image,
488
+ hwnd=hwnd,
489
+ method=method,
490
+ region=used_region,
491
+ client_only=use_client,
492
+ dpi_aware=self._dpi_entered,
493
+ attempts=attempts,
494
+ analysis=analysis,
495
+ duration=time.monotonic() - started,
496
+ )
497
+ if analysis is None or analysis.usable:
498
+ if region is not None:
499
+ frame.image = self._crop(image, used_region, region)
500
+ if save_to:
501
+ frame.save(save_to)
502
+ return frame
503
+ log.info(
504
+ "capture method %s produced a %s frame; trying the next method",
505
+ method,
506
+ analysis.reason,
507
+ )
508
+ if blank_frame is None:
509
+ blank_frame = frame
510
+
511
+ if blank_frame is not None and self.allow_blank_fallback and not must_have_content:
512
+ log.warning("returning a blank frame because require_content=False")
513
+ return blank_frame
514
+ raise CaptureError(
515
+ "every capture method failed or produced a blank frame for 0x%X" % hwnd,
516
+ code="capture-blank",
517
+ details={
518
+ "attempts": attempts,
519
+ "methods": list(chain),
520
+ "client_only": use_client,
521
+ },
522
+ hint="Hardware-accelerated or DRM-protected windows cannot be captured offscreen. "
523
+ "Try methods=('bitblt',) with the window visible on the virtual display.",
524
+ )
525
+
526
+ def ensure_dpi_aware(self, awareness: str = "per_monitor_v2") -> bool:
527
+ """Set DPI awareness once per process (never restored: other code benefits too)."""
528
+ if getattr(self, "_dpi_entered", False):
529
+ return True
530
+ if not IS_WINDOWS:
531
+ return False
532
+ scope = dpi_awareness_scope(awareness, restore=False)
533
+ scope.__enter__()
534
+ self._dpi_entered = scope.applied is not None or scope.previous is not None
535
+ log.debug("DPI awareness set to %s", awareness)
536
+ return self._dpi_entered
537
+
538
+ # -- internals -------------------------------------------------------------------
539
+ def _target_rect(self, hwnd: int, client_only: bool) -> Tuple[int, int, int, int, Tuple[int, int]]:
540
+ """Return ``(left, top, width, height, (client_w, client_h))``."""
541
+ window = _RECT()
542
+ if not user32.GetWindowRect(hwnd, ctypes.byref(window)):
543
+ raise CaptureError("GetWindowRect failed", code="geometry-failed")
544
+ if client_only:
545
+ client = _RECT()
546
+ origin = _POINT(0, 0)
547
+ if user32.GetClientRect(hwnd, ctypes.byref(client)) and user32.ClientToScreen(
548
+ hwnd, ctypes.byref(origin)
549
+ ):
550
+ width = int(client.right - client.left)
551
+ height = int(client.bottom - client.top)
552
+ if width > 0 and height > 0:
553
+ return int(origin.x), int(origin.y), width, height, (width, height)
554
+ width = int(window.right - window.left)
555
+ height = int(window.bottom - window.top)
556
+ if width <= 0 or height <= 0:
557
+ raise CaptureError(
558
+ f"window 0x{hwnd:X} has a degenerate geometry {width}x{height}",
559
+ code="degenerate-geometry",
560
+ )
561
+ return int(window.left), int(window.top), width, height, (width, height)
562
+
563
+ def _invoke(self, method: str, hwnd: int, client_only: bool):
564
+ left, top, width, height, _ = self._target_rect(hwnd, client_only)
565
+ region = (left, top, left + width, top + height)
566
+ if method == "printwindow":
567
+ return self._print_window(hwnd, width, height, client_only, PW_RENDERFULLCONTENT), region
568
+ if method == "printwindow_plain":
569
+ return self._print_window(hwnd, width, height, client_only, 0), region
570
+ if method == "wm_print":
571
+ return self._wm_print(hwnd, width, height, client_only), region
572
+ if method == "bitblt":
573
+ return self._bitblt(hwnd, left, top, width, height, client_only), region
574
+ raise CaptureError(f"unknown method {method!r}", code="unknown-method")
575
+
576
+ def _print_window(self, hwnd: int, width: int, height: int, client_only: bool, flags: int):
577
+ """``PrintWindow`` into a top-down DIB.
578
+
579
+ Validated against the approach used by production WeChat automation
580
+ (``Luofeng-Cloud/WeChat-AI-AutoReply``, ``grab_wechat_window``): ``GetWindowDC`` +
581
+ ``CreateCompatibleDC`` + ``CreateCompatibleBitmap(w, h)`` + ``PrintWindow(hwnd, dc, 2)``
582
+ with a plain-``PrintWindow`` fallback, then ``GetDIBits`` into a BGRA buffer. Using a
583
+ **screen** DC for the compatible DC and a DIB section (this implementation) is
584
+ equivalent; the differences that matter are elsewhere and are handled by the caller:
585
+ the bitmap must be the *window's* rectangle (not the monitor's), and the result must be
586
+ rejected when it is effectively empty (a max pixel value near zero).
587
+ """
588
+ with _MemoryBitmap(width, height) as bitmap:
589
+ flag = flags | (PW_CLIENTONLY if client_only and not flags else 0)
590
+ ctypes.set_last_error(0)
591
+ result = user32.PrintWindow(hwnd, bitmap.dc, flag)
592
+ if not result:
593
+ error = ctypes.get_last_error()
594
+ # A FALSE return is not always fatal: some windows paint anyway. Only
595
+ # treat a *blank* result as failure, which the caller already checks.
596
+ log.debug("PrintWindow(flags=%s) returned 0 (Win32 error %s)", flag, error)
597
+ return bitmap.to_image()
598
+
599
+ def _wm_print(self, hwnd: int, width: int, height: int, client_only: bool):
600
+ with _MemoryBitmap(width, height) as bitmap:
601
+ flags = PRF_CLIENT | PRF_CHILDREN | PRF_ERASEBKGND
602
+ if not client_only:
603
+ flags |= PRF_NONCLIENT
604
+ message = WM_PRINTCLIENT if client_only else WM_PRINT
605
+ result = ctypes.c_void_p()
606
+ ok = user32.SendMessageTimeoutW(
607
+ hwnd, message, int(bitmap.dc), flags, SMTO_ABORTIFHUNG, int(self.timeout * 1000), ctypes.byref(result)
608
+ )
609
+ if not ok:
610
+ raise CaptureError(
611
+ f"WM_PRINT timed out or was refused (Win32 error {ctypes.get_last_error()})",
612
+ code="wm-print-failed",
613
+ )
614
+ return bitmap.to_image()
615
+
616
+ def _bitblt(self, hwnd: int, left: int, top: int, width: int, height: int, client_only: bool):
617
+ with _MemoryBitmap(width, height) as bitmap:
618
+ source = user32.GetDC(hwnd) if client_only else user32.GetWindowDC(hwnd)
619
+ if not source:
620
+ raise CaptureError("GetDC(hwnd) failed", code="getdc-failed")
621
+ try:
622
+ ok = gdi32.BitBlt(
623
+ bitmap.dc, 0, 0, width, height, source, 0, 0, SRCCOPY | CAPTUREBLT
624
+ )
625
+ finally:
626
+ user32.ReleaseDC(hwnd, source)
627
+ if not ok:
628
+ raise CaptureError(
629
+ f"BitBlt from the window DC failed (Win32 error {ctypes.get_last_error()})",
630
+ code="bitblt-failed",
631
+ )
632
+ return bitmap.to_image()
633
+
634
+ @staticmethod
635
+ def _crop(image, used_region, region):
636
+ """Crop ``region`` (absolute client coordinates) out of the captured frame."""
637
+ offset_x = region[0] - used_region[0]
638
+ offset_y = region[1] - used_region[1]
639
+ box = (
640
+ max(offset_x, 0),
641
+ max(offset_y, 0),
642
+ min(offset_x + (region[2] - region[0]), image.width),
643
+ min(offset_y + (region[3] - region[1]), image.height),
644
+ )
645
+ if box[2] <= box[0] or box[3] <= box[1]:
646
+ raise CaptureError(
647
+ f"requested region {region} lies outside the captured area {used_region}",
648
+ code="region-outside",
649
+ )
650
+ return image.crop(box)
651
+
652
+ # -- diagnostics -----------------------------------------------------------------
653
+ def probe(self, hwnd: int, *, client_only: Optional[bool] = None) -> dict:
654
+ """Run every method and report per-method outcomes (used by ``check --window``)."""
655
+ results = []
656
+ use_client = self.client_only if client_only is None else client_only
657
+ for method in self.methods:
658
+ entry: Dict[str, object] = {"method": method}
659
+ try:
660
+ frame = self.capture(
661
+ hwnd, client_only=use_client, methods=[method], require_content=True
662
+ )
663
+ entry["ok"] = True
664
+ entry["size"] = list(frame.size)
665
+ entry["analysis"] = frame.analysis.to_dict() if frame.analysis else None
666
+ except CaptureError as exc:
667
+ entry["ok"] = False
668
+ entry["error"] = str(exc)
669
+ entry["code"] = exc.code
670
+ results.append(entry)
671
+ return {"hwnd": hwnd, "client_only": use_client, "methods": results}