@momasiku/dsh-pilot 0.5.7 → 0.5.8

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.
@@ -1,241 +1,267 @@
1
- # pilot / _dsh-capture.ps1 - one capture, executed in whatever process called it.
2
- #
3
- # Extracted from desktop-probe.ps1 so the same code serves two callers:
4
- # * desktop-probe.ps1 - one frame per process (the fallback path);
5
- # * desktop-worker.ps1 - frames in the warm process (the fast path).
6
- #
7
- # Differences from the one-shot script it came from: failures throw instead of
8
- # printing JSON and exiting, and the result is returned instead of being printed.
9
- $ErrorActionPreference = 'Stop'
10
-
11
- $script:CaptureStopPayload = $null
12
-
13
- function Write-ProbeError([string] $message) {
14
- $script:CaptureStopPayload = [ordered]@{ ok = $false; error = $message }
15
- throw [System.Exception]::new($message)
16
- }
17
-
18
- function Get-ProcessNameSafe([uint32] $pidValue) {
19
- try { return (Get-Process -Id $pidValue -ErrorAction Stop).ProcessName } catch { return $null }
20
- }
21
-
22
- function ConvertTo-WindowInfo([IntPtr] $handle, [bool] $withProcess) {
23
- if ($handle -eq [IntPtr]::Zero) { return $null }
24
- $title = [DshProbe]::WindowTitle($handle)
25
- $class = [DshProbe]::WindowClass($handle)
26
- $rect = [DshProbe]::WindowRect($handle)
27
- $pidValue = [uint32] 0
28
- $null = [DshProbe]::GetWindowThreadProcessId($handle, [ref] $pidValue)
29
- return [ordered]@{
30
- handle = ('0x{0:X}' -f $handle.ToInt64())
31
- title = $title
32
- class = $class
33
- process = if ($withProcess) { Get-ProcessNameSafe $pidValue } else { $null }
34
- pid = [int] $pidValue
35
- bounds = ('{0},{1},{2},{3}' -f $rect.Left, $rect.Top, ($rect.Right - $rect.Left), ($rect.Bottom - $rect.Top))
36
- }
37
- }
38
-
39
-
40
- <#
41
- Capture the desktop, a monitor, a region or one window. $Params carries out,
42
- screen, window, region and includeCursor.
43
- #>
44
- function Invoke-DshCapture {
45
- param([object] $Params)
46
-
47
- $params = if ($null -eq $Params) { [pscustomobject]@{} } else { $Params }
48
-
49
- $outPath = if ($params.PSObject.Properties.Name -contains 'out') { [string] $params.out } else { '' }
50
- if ([string]::IsNullOrWhiteSpace($outPath)) { Write-ProbeError 'params.out (the PNG destination) is required' }
51
-
52
- $screenMode = if ($params.PSObject.Properties.Name -contains 'screen' -and $null -ne $params.screen) { [string] $params.screen } else { 'primary' }
53
- $windowQuery = if ($params.PSObject.Properties.Name -contains 'window' -and $null -ne $params.window) { [string] $params.window } else { '' }
54
- $regionSpec = if ($params.PSObject.Properties.Name -contains 'region' -and $null -ne $params.region) { [string] $params.region } else { '' }
55
- $includeCursor = [bool] ($params.PSObject.Properties.Name -contains 'includeCursor' -and $params.includeCursor)
56
- $rulers = $true
57
- if ($params.PSObject.Properties.Name -contains 'rulers') { $rulers = [bool] $params.rulers }
58
-
59
-
60
- # ------------------------------------------------------------ screen inventory
61
- $screens = @()
62
- try {
63
- Add-Type -AssemblyName System.Windows.Forms
64
- $all = [System.Windows.Forms.Screen]::AllScreens
65
- foreach ($s in $all) {
66
- $screens += [ordered]@{
67
- device = $s.DeviceName
68
- primary = [bool] $s.Primary
69
- bounds = ('{0},{1},{2},{3}' -f $s.Bounds.X, $s.Bounds.Y, $s.Bounds.Width, $s.Bounds.Height)
70
- }
71
- }
72
- } catch {
73
- Write-ProbeError "the desktop is not available to this session (screen enumeration failed: $($_.Exception.Message))"
74
- }
75
- if ($screens.Count -eq 0) { Write-ProbeError 'the desktop is not available to this session (no interactive display)' }
76
-
77
- $primaryDip = $all | Where-Object { $_.Primary } | Select-Object -First 1
78
- if ($null -eq $primaryDip) { $primaryDip = $all[0] }
79
-
80
- # Resolution order: an explicit window, then a region, then the screen mode.
81
- $kind = $null
82
- $captureDip = $null
83
- $scale = $null
84
-
85
- if (-not [string]::IsNullOrWhiteSpace($windowQuery)) {
86
- $needle = $windowQuery.ToLowerInvariant()
87
- $candidate = $null
88
- foreach ($process in (Get-Process | Where-Object { $_.MainWindowHandle -ne 0 })) {
89
- $handle = $process.MainWindowHandle
90
- if (-not [DshProbe]::IsWindowVisible($handle)) { continue }
91
- $title = [DshProbe]::WindowTitle($handle)
92
- if ($title.ToLowerInvariant().Contains($needle) -or $process.ProcessName.ToLowerInvariant().Contains($needle)) {
93
- if ($null -eq $candidate) { $candidate = $process }
94
- }
95
- }
96
- if ($null -eq $candidate) { Write-ProbeError "no visible top-level window matches '$windowQuery'" }
97
- $handle = $candidate.MainWindowHandle
98
- $rect = [DshProbe]::WindowRect($handle)
99
- $captureDip = [pscustomobject]@{ X = $rect.Left; Y = $rect.Top; Width = ($rect.Right - $rect.Left); Height = ($rect.Bottom - $rect.Top) }
100
- $kind = 'window'
101
- } elseif (-not [string]::IsNullOrWhiteSpace($regionSpec)) {
102
- $parts = @($regionSpec -split '[,\s]+' | Where-Object { $_ -ne '' })
103
- if ($parts.Count -ne 4) { Write-ProbeError "region must be 'x,y,width,height' (got '$regionSpec')" }
104
- try { $r = @($parts | ForEach-Object { [int] $_ }) } catch { Write-ProbeError "region must contain four integers (got '$regionSpec')" }
105
- if ($r[2] -le 0 -or $r[3] -le 0) { Write-ProbeError 'region width and height must be positive' }
106
- $captureDip = [pscustomobject]@{ X = $r[0]; Y = $r[1]; Width = $r[2]; Height = $r[3] }
107
- $kind = 'region'
108
- } elseif ($screenMode -eq 'all') {
109
- $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
110
- $captureDip = [pscustomobject]@{ X = $bounds.X; Y = $bounds.Y; Width = $bounds.Width; Height = $bounds.Height }
111
- $kind = 'all'
112
- } elseif ($screenMode -eq 'primary') {
113
- $captureDip = $primaryDip.Bounds
114
- $kind = 'primary'
115
- } else {
116
- $index = 0
117
- if (-not [int]::TryParse($screenMode, [ref] $index)) { Write-ProbeError "screen must be 'primary', 'all', or a monitor index (got '$screenMode')" }
118
- if ($index -lt 0 -or $index -ge $screens.Count) { Write-ProbeError "monitor index $index is out of range (0..$($screens.Count - 1))" }
119
- $captureDip = $all[$index].Bounds
120
- $kind = "screen:$index"
121
- }
122
-
123
- # --------------------------------------------------------------------- capture
124
- Add-Type -AssemblyName System.Drawing
125
- $bitmap = New-Object System.Drawing.Bitmap($captureDip.Width, $captureDip.Height)
126
- $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
127
- $imageWidth = 0
128
- $imageHeight = 0
129
- try {
130
- $graphics.CopyFromScreen($captureDip.X, $captureDip.Y, 0, 0, $bitmap.Size)
131
- if ($includeCursor) {
132
- try {
133
- $cursor = [DshProbe]::Cursor()
134
- $cx = $cursor.X - $captureDip.X
135
- $cy = $cursor.Y - $captureDip.Y
136
- if ($cx -ge 0 -and $cy -ge 0 -and $cx -lt $bitmap.Width -and $cy -lt $bitmap.Height) {
137
- $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 255, 0, 0)), 2
138
- $graphics.DrawLine($pen, ($cx - 14), $cy, ($cx + 14), $cy)
139
- $graphics.DrawLine($pen, $cx, ($cy - 14), $cx, ($cy + 14))
140
- $pen.Dispose()
141
- }
142
- } catch {
143
- # A cursor overlay is cosmetic: never fail a capture over it.
144
- }
145
- }
146
- if ($rulers) {
147
- # Coordinate rulers: ticks every $rulerStep pixels along the top and left
148
- # edge, each labelled with the SCREEN coordinate it sits at (capture origin
149
- # plus the local offset). The model can then read a position straight off
150
- # the image instead of counting pixels, which also survives any later
151
- # downscaling by the attachment store - the ratio in the envelope says what
152
- # the labels have to be multiplied by.
153
- try {
154
- $rulerStep = if ($bitmap.Width -ge 800) { 200 } else { 100 }
155
- $font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold)
156
- $ink = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 255, 64, 64))
157
- $shadow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(200, 0, 0, 0))
158
- $tick = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 255, 64, 64)), 1
159
- try {
160
- for ($x = $rulerStep; $x -lt $bitmap.Width; $x += $rulerStep) {
161
- $graphics.DrawLine($tick, $x, 0, $x, 10)
162
- $label = [string] ([int] $captureDip.X + $x)
163
- $graphics.DrawString($label, $font, $shadow, ($x + 2), 13)
164
- $graphics.DrawString($label, $font, $ink, ($x + 1), 12)
165
- }
166
- for ($y = $rulerStep; $y -lt $bitmap.Height; $y += $rulerStep) {
167
- $graphics.DrawLine($tick, 0, $y, 10, $y)
168
- $label = [string] ([int] $captureDip.Y + $y)
169
- $graphics.DrawString($label, $font, $shadow, 13, ($y + 2))
170
- $graphics.DrawString($label, $font, $ink, 12, ($y + 1))
171
- }
172
- $originLabel = ('origin {0},{1} step {2}' -f [int] $captureDip.X, [int] $captureDip.Y, $rulerStep)
173
- $graphics.DrawString($originLabel, $font, $shadow, 15, 30)
174
- $graphics.DrawString($originLabel, $font, $ink, 14, 29)
175
- } finally {
176
- $tick.Dispose()
177
- $font.Dispose()
178
- $ink.Dispose()
179
- $shadow.Dispose()
180
- }
181
- } catch {
182
- # Rulers are an aid: never fail a capture over them.
183
- }
184
- }
185
- $directory = Split-Path -Parent $outPath
186
- if (-not [string]::IsNullOrEmpty($directory) -and -not (Test-Path -LiteralPath $directory)) {
187
- $null = New-Item -ItemType Directory -Path $directory -Force
188
- }
189
- $bitmap.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png)
190
- $imageWidth = $bitmap.Width
191
- $imageHeight = $bitmap.Height
192
- } catch {
193
- Write-ProbeError "screen capture failed: $($_.Exception.Message)"
194
- } finally {
195
- $graphics.Dispose()
196
- $bitmap.Dispose()
197
- }
198
-
199
- # DIP -> physical. Both the capture rect and the DIP screen rect come from the
200
- # same monitor, so the ratio is the monitor's real scale factor.
201
- if ($captureDip.Width -gt 0) { $scale = [math]::Round(($imageWidth / $captureDip.Width), 4) } else { $scale = 1 }
202
- $originX = [int] [math]::Round($captureDip.X * $scale)
203
- $originY = [int] [math]::Round($captureDip.Y * $scale)
204
-
205
- # ------------------------------------------------------------------- foreground
206
- $foreground = ConvertTo-WindowInfo ([DshProbe]::GetForegroundWindow()) $true
207
- $cursorInfo = $null
208
- try {
209
- $cursor = [DshProbe]::Cursor()
210
- $screen = $screens | Where-Object {
211
- $b = ($_.bounds -split ',') | ForEach-Object { [int] $_ }
212
- $cursor.X -ge $b[0] -and $cursor.X -lt ($b[0] + $b[2]) -and $cursor.Y -ge $b[1] -and $cursor.Y -lt ($b[1] + $b[3])
213
- } | Select-Object -First 1
214
- $cursorInfo = [ordered]@{
215
- x = $cursor.X
216
- y = $cursor.Y
217
- screen = if ($null -ne $screen) { $screen.device } else { $null }
218
- }
219
- } catch {
220
- $cursorInfo = $null
221
- }
222
-
223
- $result = [ordered]@{
224
- ok = $true
225
- path = (Resolve-Path -LiteralPath $outPath).Path
226
- kind = $kind
227
- imageWidth = $imageWidth
228
- imageHeight = $imageHeight
229
- originX = $originX
230
- originY = $originY
231
- scale = $scale
232
- dpiAware = $true
233
- dipBounds = ('{0},{1},{2},{3}' -f $captureDip.X, $captureDip.Y, $captureDip.Width, $captureDip.Height)
234
- screens = $screens
235
- cursor = $cursorInfo
236
- foregroundWindow = $foreground
237
- rulers = [bool] $rulers
238
- capturedAt = [DateTimeOffset]::Now.ToString('o')
239
- }
240
- return $result
1
+ # pilot / _dsh-capture.ps1 - one capture, executed in whatever process called it.
2
+ #
3
+ # Extracted from desktop-probe.ps1 so the same code serves two callers:
4
+ # * desktop-probe.ps1 - one frame per process (the fallback path);
5
+ # * desktop-worker.ps1 - frames in the warm process (the fast path).
6
+ #
7
+ # Differences from the one-shot script it came from: failures throw instead of
8
+ # printing JSON and exiting, and the result is returned instead of being printed.
9
+ $ErrorActionPreference = 'Stop'
10
+
11
+ $script:CaptureStopPayload = $null
12
+
13
+ function Write-ProbeError([string] $message) {
14
+ $script:CaptureStopPayload = [ordered]@{ ok = $false; error = $message }
15
+ throw [System.Exception]::new($message)
16
+ }
17
+
18
+ function Get-ProcessNameSafe([uint32] $pidValue) {
19
+ try { return (Get-Process -Id $pidValue -ErrorAction Stop).ProcessName } catch { return $null }
20
+ }
21
+
22
+ function ConvertTo-WindowInfo([IntPtr] $handle, [bool] $withProcess) {
23
+ if ($handle -eq [IntPtr]::Zero) { return $null }
24
+ $title = [DshProbe]::WindowTitle($handle)
25
+ $class = [DshProbe]::WindowClass($handle)
26
+ $rect = [DshProbe]::WindowRect($handle)
27
+ $pidValue = [uint32] 0
28
+ $null = [DshProbe]::GetWindowThreadProcessId($handle, [ref] $pidValue)
29
+ return [ordered]@{
30
+ handle = ('0x{0:X}' -f $handle.ToInt64())
31
+ title = $title
32
+ class = $class
33
+ process = if ($withProcess) { Get-ProcessNameSafe $pidValue } else { $null }
34
+ pid = [int] $pidValue
35
+ bounds = ('{0},{1},{2},{3}' -f $rect.Left, $rect.Top, ($rect.Right - $rect.Left), ($rect.Bottom - $rect.Top))
36
+ }
37
+ }
38
+
39
+
40
+ <#
41
+ Capture the desktop, a monitor, a region or one window. $Params carries out,
42
+ screen, window, region and includeCursor.
43
+ #>
44
+ function Invoke-DshCapture {
45
+ param([object] $Params)
46
+
47
+ $params = if ($null -eq $Params) { [pscustomobject]@{} } else { $Params }
48
+
49
+ $outPath = if ($params.PSObject.Properties.Name -contains 'out') { [string] $params.out } else { '' }
50
+ if ([string]::IsNullOrWhiteSpace($outPath)) { Write-ProbeError 'params.out (the PNG destination) is required' }
51
+
52
+ $screenMode = if ($params.PSObject.Properties.Name -contains 'screen' -and $null -ne $params.screen) { [string] $params.screen } else { 'primary' }
53
+ $windowQuery = if ($params.PSObject.Properties.Name -contains 'window' -and $null -ne $params.window) { [string] $params.window } else { '' }
54
+ $regionSpec = if ($params.PSObject.Properties.Name -contains 'region' -and $null -ne $params.region) { [string] $params.region } else { '' }
55
+ $includeCursor = [bool] ($params.PSObject.Properties.Name -contains 'includeCursor' -and $params.includeCursor)
56
+ $rulers = $true
57
+ if ($params.PSObject.Properties.Name -contains 'rulers') { $rulers = [bool] $params.rulers }
58
+
59
+
60
+ # ------------------------------------------------------------ screen inventory
61
+ # Re-enumerated on every capture, deliberately.
62
+ #
63
+ # System.Windows.Forms.Screen.AllScreens is a static cache that only refreshes
64
+ # when the process pumps WM_DISPLAYCHANGE. The warm worker is a -NonInteractive
65
+ # read loop with no message pump, so that cache is never invalidated: after a
66
+ # resolution change the worker keeps reporting the geometry it saw at startup.
67
+ # Measured here - a worker alive 13 hours reported
68
+ # <screens>primary WinDisc 0,0,1600,900</screens> while the same result carried
69
+ # <image_size>1024x768</image_size>. Restarting the worker produced the correct
70
+ # 1024x768. Two answers to one question, inside the very result the coordinate
71
+ # contract is read from. EnumDisplayMonitors re-queries per call, so there is no
72
+ # cache to go stale.
73
+ $screens = @()
74
+ $screenBounds = @()
75
+ $rows = @()
76
+ try {
77
+ $rows = @([DshProbe]::Monitors())
78
+ } catch {
79
+ Write-ProbeError "the desktop is not available to this session (screen enumeration failed: $($_.Exception.Message))"
80
+ }
81
+ foreach ($row in $rows) {
82
+ $parts = $row -split '\|'
83
+ if ($parts.Count -lt 6) { continue }
84
+ $left = [int] $parts[2]; $top = [int] $parts[3]; $right = [int] $parts[4]; $bottom = [int] $parts[5]
85
+ if (($right - $left) -le 0 -or ($bottom - $top) -le 0) { continue }
86
+ $screens += [ordered]@{
87
+ device = $parts[0]
88
+ primary = ($parts[1] -eq '1')
89
+ bounds = ('{0},{1},{2},{3}' -f $left, $top, ($right - $left), ($bottom - $top))
90
+ }
91
+ $screenBounds += [pscustomobject]@{ X = $left; Y = $top; Width = ($right - $left); Height = ($bottom - $top) }
92
+ }
93
+ if ($screens.Count -eq 0) { Write-ProbeError 'the desktop is not available to this session (no interactive display)' }
94
+
95
+ $primaryIndex = 0
96
+ for ($i = 0; $i -lt $screens.Count; $i++) { if ($screens[$i].primary) { $primaryIndex = $i; break } }
97
+ $primaryDip = $screenBounds[$primaryIndex]
98
+
99
+ # Resolution order: an explicit window, then a region, then the screen mode.
100
+ $kind = $null
101
+ $captureDip = $null
102
+ $scale = $null
103
+
104
+ if (-not [string]::IsNullOrWhiteSpace($windowQuery)) {
105
+ $needle = $windowQuery.ToLowerInvariant()
106
+ $candidate = $null
107
+ foreach ($process in (Get-Process | Where-Object { $_.MainWindowHandle -ne 0 })) {
108
+ $handle = $process.MainWindowHandle
109
+ if (-not [DshProbe]::IsWindowVisible($handle)) { continue }
110
+ $title = [DshProbe]::WindowTitle($handle)
111
+ if ($title.ToLowerInvariant().Contains($needle) -or $process.ProcessName.ToLowerInvariant().Contains($needle)) {
112
+ if ($null -eq $candidate) { $candidate = $process }
113
+ }
114
+ }
115
+ if ($null -eq $candidate) { Write-ProbeError "no visible top-level window matches '$windowQuery'" }
116
+ $handle = $candidate.MainWindowHandle
117
+ $rect = [DshProbe]::WindowRect($handle)
118
+ $captureDip = [pscustomobject]@{ X = $rect.Left; Y = $rect.Top; Width = ($rect.Right - $rect.Left); Height = ($rect.Bottom - $rect.Top) }
119
+ $kind = 'window'
120
+ } elseif (-not [string]::IsNullOrWhiteSpace($regionSpec)) {
121
+ $parts = @($regionSpec -split '[,\s]+' | Where-Object { $_ -ne '' })
122
+ if ($parts.Count -ne 4) { Write-ProbeError "region must be 'x,y,width,height' (got '$regionSpec')" }
123
+ try { $r = @($parts | ForEach-Object { [int] $_ }) } catch { Write-ProbeError "region must contain four integers (got '$regionSpec')" }
124
+ if ($r[2] -le 0 -or $r[3] -le 0) { Write-ProbeError 'region width and height must be positive' }
125
+ $captureDip = [pscustomobject]@{ X = $r[0]; Y = $r[1]; Width = $r[2]; Height = $r[3] }
126
+ $kind = 'region'
127
+ } elseif ($screenMode -eq 'all') {
128
+ # SM_XVIRTUALSCREEN(76), SM_YVIRTUALSCREEN(77), SM_CXVIRTUALSCREEN(78), SM_CYVIRTUALSCREEN(79).
129
+ # GetSystemMetrics is a live call; SystemInformation.VirtualScreen comes from the
130
+ # same WinForms cache as Screen.AllScreens and goes stale for the same reason.
131
+ $vx = [DshProbe]::GetSystemMetrics(76)
132
+ $vy = [DshProbe]::GetSystemMetrics(77)
133
+ $vw = [DshProbe]::GetSystemMetrics(78)
134
+ $vh = [DshProbe]::GetSystemMetrics(79)
135
+ if ($vw -le 0 -or $vh -le 0) { $vx = $primaryDip.X; $vy = $primaryDip.Y; $vw = $primaryDip.Width; $vh = $primaryDip.Height }
136
+ $captureDip = [pscustomobject]@{ X = $vx; Y = $vy; Width = $vw; Height = $vh }
137
+ $kind = 'all'
138
+ } elseif ($screenMode -eq 'primary') {
139
+ $captureDip = $primaryDip
140
+ $kind = 'primary'
141
+ } else {
142
+ $index = 0
143
+ if (-not [int]::TryParse($screenMode, [ref] $index)) { Write-ProbeError "screen must be 'primary', 'all', or a monitor index (got '$screenMode')" }
144
+ if ($index -lt 0 -or $index -ge $screens.Count) { Write-ProbeError "monitor index $index is out of range (0..$($screens.Count - 1))" }
145
+ $captureDip = $screenBounds[$index]
146
+ $kind = "screen:$index"
147
+ }
148
+
149
+ # --------------------------------------------------------------------- capture
150
+ Add-Type -AssemblyName System.Drawing
151
+ $bitmap = New-Object System.Drawing.Bitmap($captureDip.Width, $captureDip.Height)
152
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
153
+ $imageWidth = 0
154
+ $imageHeight = 0
155
+ try {
156
+ $graphics.CopyFromScreen($captureDip.X, $captureDip.Y, 0, 0, $bitmap.Size)
157
+ if ($includeCursor) {
158
+ try {
159
+ $cursor = [DshProbe]::Cursor()
160
+ $cx = $cursor.X - $captureDip.X
161
+ $cy = $cursor.Y - $captureDip.Y
162
+ if ($cx -ge 0 -and $cy -ge 0 -and $cx -lt $bitmap.Width -and $cy -lt $bitmap.Height) {
163
+ $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 255, 0, 0)), 2
164
+ $graphics.DrawLine($pen, ($cx - 14), $cy, ($cx + 14), $cy)
165
+ $graphics.DrawLine($pen, $cx, ($cy - 14), $cx, ($cy + 14))
166
+ $pen.Dispose()
167
+ }
168
+ } catch {
169
+ # A cursor overlay is cosmetic: never fail a capture over it.
170
+ }
171
+ }
172
+ if ($rulers) {
173
+ # Coordinate rulers: ticks every $rulerStep pixels along the top and left
174
+ # edge, each labelled with the SCREEN coordinate it sits at (capture origin
175
+ # plus the local offset). The model can then read a position straight off
176
+ # the image instead of counting pixels, which also survives any later
177
+ # downscaling by the attachment store - the ratio in the envelope says what
178
+ # the labels have to be multiplied by.
179
+ try {
180
+ $rulerStep = if ($bitmap.Width -ge 800) { 200 } else { 100 }
181
+ $font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold)
182
+ $ink = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 255, 64, 64))
183
+ $shadow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(200, 0, 0, 0))
184
+ $tick = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 255, 64, 64)), 1
185
+ try {
186
+ for ($x = $rulerStep; $x -lt $bitmap.Width; $x += $rulerStep) {
187
+ $graphics.DrawLine($tick, $x, 0, $x, 10)
188
+ $label = [string] ([int] $captureDip.X + $x)
189
+ $graphics.DrawString($label, $font, $shadow, ($x + 2), 13)
190
+ $graphics.DrawString($label, $font, $ink, ($x + 1), 12)
191
+ }
192
+ for ($y = $rulerStep; $y -lt $bitmap.Height; $y += $rulerStep) {
193
+ $graphics.DrawLine($tick, 0, $y, 10, $y)
194
+ $label = [string] ([int] $captureDip.Y + $y)
195
+ $graphics.DrawString($label, $font, $shadow, 13, ($y + 2))
196
+ $graphics.DrawString($label, $font, $ink, 12, ($y + 1))
197
+ }
198
+ $originLabel = ('origin {0},{1} step {2}' -f [int] $captureDip.X, [int] $captureDip.Y, $rulerStep)
199
+ $graphics.DrawString($originLabel, $font, $shadow, 15, 30)
200
+ $graphics.DrawString($originLabel, $font, $ink, 14, 29)
201
+ } finally {
202
+ $tick.Dispose()
203
+ $font.Dispose()
204
+ $ink.Dispose()
205
+ $shadow.Dispose()
206
+ }
207
+ } catch {
208
+ # Rulers are an aid: never fail a capture over them.
209
+ }
210
+ }
211
+ $directory = Split-Path -Parent $outPath
212
+ if (-not [string]::IsNullOrEmpty($directory) -and -not (Test-Path -LiteralPath $directory)) {
213
+ $null = New-Item -ItemType Directory -Path $directory -Force
214
+ }
215
+ $bitmap.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png)
216
+ $imageWidth = $bitmap.Width
217
+ $imageHeight = $bitmap.Height
218
+ } catch {
219
+ Write-ProbeError "screen capture failed: $($_.Exception.Message)"
220
+ } finally {
221
+ $graphics.Dispose()
222
+ $bitmap.Dispose()
223
+ }
224
+
225
+ # DIP -> physical. Both the capture rect and the DIP screen rect come from the
226
+ # same monitor, so the ratio is the monitor's real scale factor.
227
+ if ($captureDip.Width -gt 0) { $scale = [math]::Round(($imageWidth / $captureDip.Width), 4) } else { $scale = 1 }
228
+ $originX = [int] [math]::Round($captureDip.X * $scale)
229
+ $originY = [int] [math]::Round($captureDip.Y * $scale)
230
+
231
+ # ------------------------------------------------------------------- foreground
232
+ $foreground = ConvertTo-WindowInfo ([DshProbe]::GetForegroundWindow()) $true
233
+ $cursorInfo = $null
234
+ try {
235
+ $cursor = [DshProbe]::Cursor()
236
+ $screen = $screens | Where-Object {
237
+ $b = ($_.bounds -split ',') | ForEach-Object { [int] $_ }
238
+ $cursor.X -ge $b[0] -and $cursor.X -lt ($b[0] + $b[2]) -and $cursor.Y -ge $b[1] -and $cursor.Y -lt ($b[1] + $b[3])
239
+ } | Select-Object -First 1
240
+ $cursorInfo = [ordered]@{
241
+ x = $cursor.X
242
+ y = $cursor.Y
243
+ screen = if ($null -ne $screen) { $screen.device } else { $null }
244
+ }
245
+ } catch {
246
+ $cursorInfo = $null
247
+ }
248
+
249
+ $result = [ordered]@{
250
+ ok = $true
251
+ path = (Resolve-Path -LiteralPath $outPath).Path
252
+ kind = $kind
253
+ imageWidth = $imageWidth
254
+ imageHeight = $imageHeight
255
+ originX = $originX
256
+ originY = $originY
257
+ scale = $scale
258
+ dpiAware = $true
259
+ dipBounds = ('{0},{1},{2},{3}' -f $captureDip.X, $captureDip.Y, $captureDip.Width, $captureDip.Height)
260
+ screens = $screens
261
+ cursor = $cursorInfo
262
+ foregroundWindow = $foreground
263
+ rulers = [bool] $rulers
264
+ capturedAt = [DateTimeOffset]::Now.ToString('o')
265
+ }
266
+ return $result
241
267
  }