dsh-spectacles 0.0.0-stage → 1.1.2
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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +202 -0
- package/README.md +146 -2
- package/README.zh.md +147 -0
- package/client.js +392 -0
- package/cordis.patch.yml +3 -0
- package/index.js +762 -0
- package/package.json +62 -3
- package/scripts/look-pick.ps1 +83 -0
- package/scripts/look-region.ps1 +195 -0
- package/scripts/look.ps1 +133 -0
- package/scripts/look2.ps1 +245 -0
- package/scripts/read-text.ps1 +198 -0
- package/scripts/selftest.ps1 +152 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Window = "",
|
|
3
|
+
[string]$Crop = "",
|
|
4
|
+
[double]$Scale = 1.0,
|
|
5
|
+
[switch]$Image,
|
|
6
|
+
[switch]$Raise,
|
|
7
|
+
[switch]$List,
|
|
8
|
+
[string]$Out = "",
|
|
9
|
+
[string]$SaveDir = ""
|
|
10
|
+
)
|
|
11
|
+
$DataDir = if ($env:DSH_LOOK_DATA) { $env:DSH_LOOK_DATA } else { Join-Path $env:TEMP 'dsh-look' }
|
|
12
|
+
if (-not (Test-Path $DataDir)) { New-Item -ItemType Directory -Force -Path $DataDir | Out-Null }
|
|
13
|
+
if (-not $SaveDir) { $SaveDir = $DataDir }
|
|
14
|
+
$ErrorActionPreference = 'Stop'
|
|
15
|
+
|
|
16
|
+
Add-Type -AssemblyName System.Drawing
|
|
17
|
+
Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @"
|
|
18
|
+
using System;
|
|
19
|
+
using System.Text;
|
|
20
|
+
using System.Collections.Generic;
|
|
21
|
+
using System.Runtime.InteropServices;
|
|
22
|
+
|
|
23
|
+
public class W2 {
|
|
24
|
+
public delegate bool EnumProc(IntPtr h, IntPtr p);
|
|
25
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
|
|
26
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
|
|
27
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
|
28
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
29
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
|
30
|
+
[DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int idx);
|
|
31
|
+
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr hdc, uint flags);
|
|
32
|
+
[DllImport("user32.dll", SetLastError = true)] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
|
33
|
+
|
|
34
|
+
public struct RECT { public int Left, Top, Right, Bottom; }
|
|
35
|
+
|
|
36
|
+
public class Win { public IntPtr H; public string Title; public uint Pid; public int L, T, R, B; }
|
|
37
|
+
|
|
38
|
+
public static List<Win> List() {
|
|
39
|
+
var res = new List<Win>();
|
|
40
|
+
EnumWindows((h, p) => {
|
|
41
|
+
if (!IsWindowVisible(h)) return true;
|
|
42
|
+
var sb = new StringBuilder(512);
|
|
43
|
+
GetWindowText(h, sb, 512);
|
|
44
|
+
string t = sb.ToString();
|
|
45
|
+
if (t.Length == 0) return true;
|
|
46
|
+
RECT r; if (!GetWindowRect(h, out r)) return true;
|
|
47
|
+
uint pid; GetWindowThreadProcessId(h, out pid);
|
|
48
|
+
var w = new Win(); w.H = h; w.Title = t; w.Pid = pid;
|
|
49
|
+
w.L = r.Left; w.T = r.Top; w.R = r.Right; w.B = r.Bottom;
|
|
50
|
+
res.Add(w);
|
|
51
|
+
return true;
|
|
52
|
+
}, IntPtr.Zero);
|
|
53
|
+
return res;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
"@
|
|
57
|
+
|
|
58
|
+
function Resolve-Window([string]$sel) {
|
|
59
|
+
$all = [W2]::List()
|
|
60
|
+
|
|
61
|
+
# picked:用人点过的那一个(look-pick.ps1 写 picked.json)
|
|
62
|
+
if ($sel -eq 'picked') {
|
|
63
|
+
$pf = Join-Path $DataDir 'picked.json'
|
|
64
|
+
if (-not (Test-Path $pf)) { return $null }
|
|
65
|
+
$pj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
66
|
+
$h = [IntPtr][long]$pj.hwnd
|
|
67
|
+
return ($all | Where-Object { $_.H -eq $h } | Select-Object -First 1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# @名字:查白名单 windows.json
|
|
71
|
+
if ($sel.StartsWith('@')) {
|
|
72
|
+
$name = $sel.Substring(1)
|
|
73
|
+
$cf = Join-Path $DataDir 'windows.json'
|
|
74
|
+
if (-not (Test-Path $cf)) { return $null }
|
|
75
|
+
$cfg = Get-Content $cf -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
76
|
+
$entry = $cfg.windows | Where-Object { $_.name -eq $name } | Select-Object -First 1
|
|
77
|
+
if (-not $entry) { return $null }
|
|
78
|
+
if ($entry.title) { return ($all | Where-Object { $_.Title -like "*$($entry.title)*" } | Select-Object -First 1) }
|
|
79
|
+
return $null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if ($sel -match '^\d+$') {
|
|
83
|
+
$h = [IntPtr][long]$sel
|
|
84
|
+
return ($all | Where-Object { $_.H -eq $h } | Select-Object -First 1)
|
|
85
|
+
}
|
|
86
|
+
if ($sel -match '^pid:(\d+)$') {
|
|
87
|
+
$p = [uint32]$Matches[1]
|
|
88
|
+
return ($all | Where-Object { $_.Pid -eq $p } | Select-Object -First 1)
|
|
89
|
+
}
|
|
90
|
+
return ($all | Where-Object { $_.Title -like "*$sel*" } | Select-Object -First 1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if ($List) {
|
|
94
|
+
$all = [W2]::List() | Where-Object { ($_.R - $_.L) -gt 0 -and ($_.B - $_.T) -gt 0 }
|
|
95
|
+
$rows = @()
|
|
96
|
+
foreach ($w in $all) {
|
|
97
|
+
$rows += [ordered]@{
|
|
98
|
+
hwnd = [int64]$w.H
|
|
99
|
+
pid = $w.Pid
|
|
100
|
+
title = $w.Title
|
|
101
|
+
rect = [ordered]@{ x = $w.L; y = $w.T; w = ($w.R - $w.L); h = ($w.B - $w.T) }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
[ordered]@{ ok = $true; count = $rows.Count; windows = $rows } | ConvertTo-Json -Compress -Depth 6
|
|
105
|
+
exit 0
|
|
106
|
+
}
|
|
107
|
+
if (-not $Window) { throw "need -Window (hwnd | title | pid:1234)" }
|
|
108
|
+
|
|
109
|
+
$w = Resolve-Window $Window
|
|
110
|
+
if (-not $w) { @{ ok = $false; error = "window not found: $Window" } | ConvertTo-Json -Compress; exit 1 }
|
|
111
|
+
|
|
112
|
+
$ww = $w.R - $w.L
|
|
113
|
+
$wh = $w.B - $w.T
|
|
114
|
+
if ($ww -le 0 -or $wh -le 0) { @{ ok = $false; error = "bad rect" } | ConvertTo-Json -Compress; exit 1 }
|
|
115
|
+
|
|
116
|
+
$full = [System.Drawing.Bitmap]::new([int]$ww, [int]$wh)
|
|
117
|
+
$g = [System.Drawing.Graphics]::FromImage($full)
|
|
118
|
+
$hdc = $g.GetHdc()
|
|
119
|
+
$ok = [W2]::PrintWindow($w.H, $hdc, 2)
|
|
120
|
+
$g.ReleaseHdc($hdc)
|
|
121
|
+
$g.Dispose()
|
|
122
|
+
$source = 'printwindow'
|
|
123
|
+
if (-not $ok) {
|
|
124
|
+
if (-not $Raise) {
|
|
125
|
+
@{ ok = $false; error = 'printwindow-failed' } | ConvertTo-Json -Compress
|
|
126
|
+
exit 1
|
|
127
|
+
}
|
|
128
|
+
# 这个窗口不吃「重画」这条请求(GPU 直绘常见)。临时把它提到最上层,从屏幕抓它那一块,再立刻还原。
|
|
129
|
+
# 不抢焦点(NOACTIVATE)、不移动、不改大小。
|
|
130
|
+
$exBefore = [W2]::GetWindowLong($w.H, -20)
|
|
131
|
+
$wasTop = (($exBefore -band 0x0008) -ne 0)
|
|
132
|
+
$raised = [W2]::SetWindowPos($w.H, [IntPtr](-1), 0, 0, 0, 0, (0x0002 -bor 0x0001 -bor 0x0010))
|
|
133
|
+
if (-not $raised) {
|
|
134
|
+
# 置顶被拒(有些游戏/带反截屏保护的程序会拒绝 SetWindowPos)。这时屏幕那块是别的窗口,抓了就是错图,宁可报错。
|
|
135
|
+
$werr = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
|
136
|
+
@{ ok = $false; error = 'raise-failed'; win32Error = $werr; hint = '这个窗口不让被提到最前,此时抓屏会抓到压在上面的窗口,所以没有出图' } | ConvertTo-Json -Compress
|
|
137
|
+
exit 1
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
Start-Sleep -Milliseconds 90
|
|
141
|
+
$g2 = [System.Drawing.Graphics]::FromImage($full)
|
|
142
|
+
$sz = New-Object System.Drawing.Size ([int]$ww), ([int]$wh)
|
|
143
|
+
$g2.CopyFromScreen([int]$w.L, [int]$w.T, 0, 0, $sz)
|
|
144
|
+
$g2.Dispose()
|
|
145
|
+
$source = 'screen-raised'
|
|
146
|
+
} finally {
|
|
147
|
+
if (-not $wasTop) {
|
|
148
|
+
[W2]::SetWindowPos($w.H, [IntPtr](-2), 0, 0, 0, 0, (0x0002 -bor 0x0001 -bor 0x0010)) | Out-Null
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# 可选区域裁剪(相对窗口左上角)
|
|
154
|
+
$rect = New-Object System.Drawing.Rectangle 0, 0, $ww, $wh
|
|
155
|
+
if ($Crop) {
|
|
156
|
+
$c = $Crop -split ','
|
|
157
|
+
$rect = New-Object System.Drawing.Rectangle ([int]$c[0]), ([int]$c[1]), ([int]$c[2]), ([int]$c[3])
|
|
158
|
+
if ($rect.X -lt 0) { $rect.X = 0 }
|
|
159
|
+
if ($rect.Y -lt 0) { $rect.Y = 0 }
|
|
160
|
+
if ($rect.X + $rect.Width -gt $ww) { $rect.Width = $ww - $rect.X }
|
|
161
|
+
if ($rect.Y + $rect.Height -gt $wh) { $rect.Height = $wh - $rect.Y }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
$img = $full.Clone($rect, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
|
165
|
+
|
|
166
|
+
# 统计:平均色 + 边缘密度 + 64x36 灰度网格(用于比对"有没有变")
|
|
167
|
+
$bw = 64; $bh = 36
|
|
168
|
+
$small = [System.Drawing.Bitmap]::new([int]$bw, [int]$bh)
|
|
169
|
+
$gs = [System.Drawing.Graphics]::FromImage($small)
|
|
170
|
+
$gs.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
171
|
+
$gs.DrawImage($img, 0, 0, $bw, $bh)
|
|
172
|
+
$gs.Dispose()
|
|
173
|
+
|
|
174
|
+
$grid = New-Object 'int[]' ($bw * $bh)
|
|
175
|
+
$sumR = 0.0; $sumG = 0.0; $sumB = 0.0; $n = 0
|
|
176
|
+
for ($y = 0; $y -lt $bh; $y++) {
|
|
177
|
+
for ($x = 0; $x -lt $bw; $x++) {
|
|
178
|
+
$c = $small.GetPixel($x, $y)
|
|
179
|
+
$gray = [int](0.299 * $c.R + 0.587 * $c.G + 0.114 * $c.B)
|
|
180
|
+
$grid[$y * $bw + $x] = $gray
|
|
181
|
+
$sumR += $c.R; $sumG += $c.G; $sumB += $c.B; $n++
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# 边缘密度:相邻像素差 > 24 的比例
|
|
186
|
+
$edge = 0; $tot = 0
|
|
187
|
+
for ($y = 0; $y -lt $bh; $y++) {
|
|
188
|
+
for ($x = 1; $x -lt $bw; $x++) {
|
|
189
|
+
$d = [Math]::Abs($grid[$y * $bw + $x] - $grid[$y * $bw + $x - 1])
|
|
190
|
+
if ($d -gt 24) { $edge++ }
|
|
191
|
+
$tot++
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
$edgeDensity = [Math]::Round($edge / [Math]::Max(1, $tot), 4)
|
|
195
|
+
|
|
196
|
+
$key = "w$($w.Pid)_$($rect.X)_$($rect.Y)_$($rect.Width)x$($rect.Height)"
|
|
197
|
+
if (-not (Test-Path $SaveDir)) { New-Item -ItemType Directory -Path $SaveDir -Force | Out-Null }
|
|
198
|
+
$lastFile = Join-Path $SaveDir "last-$key.txt"
|
|
199
|
+
$sig = ($grid -join ',')
|
|
200
|
+
$changed = $null
|
|
201
|
+
if (Test-Path $lastFile) {
|
|
202
|
+
$prev = Get-Content $lastFile -Raw
|
|
203
|
+
if ($prev) { $changed = -not ($prev -eq $sig) }
|
|
204
|
+
}
|
|
205
|
+
Set-Content -Path $lastFile -Value $sig -NoNewline
|
|
206
|
+
|
|
207
|
+
$pngPath = $null
|
|
208
|
+
if ($Image) {
|
|
209
|
+
if (-not $Out) { $Out = Join-Path $SaveDir ("win-" + (Get-Date -Format 'HHmmss') + ".png") }
|
|
210
|
+
$dir = Split-Path $Out -Parent
|
|
211
|
+
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
|
212
|
+
if ($Scale -gt 1) {
|
|
213
|
+
$sx = [int]($img.Width * $Scale); $sy = [int]($img.Height * $Scale)
|
|
214
|
+
$big = [System.Drawing.Bitmap]::new([int]$sx, [int]$sy)
|
|
215
|
+
$gb = [System.Drawing.Graphics]::FromImage($big)
|
|
216
|
+
$gb.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
217
|
+
$gb.DrawImage($img, 0, 0, $sx, $sy)
|
|
218
|
+
$gb.Dispose()
|
|
219
|
+
$big.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
220
|
+
$big.Dispose()
|
|
221
|
+
} else {
|
|
222
|
+
$img.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
223
|
+
}
|
|
224
|
+
$pngPath = $Out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
$avg = "#{0:X2}{1:X2}{2:X2}" -f [int]($sumR / $n), [int]($sumG / $n), [int]($sumB / $n)
|
|
228
|
+
|
|
229
|
+
$res = [ordered]@{
|
|
230
|
+
ok = $true
|
|
231
|
+
when = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')
|
|
232
|
+
source = $source
|
|
233
|
+
window = [ordered]@{ hwnd = [int64]$w.H; title = $w.Title; pid = $w.Pid; rect = @($w.L, $w.T, $ww, $wh) }
|
|
234
|
+
region = @($rect.X, $rect.Y, $rect.Width, $rect.Height)
|
|
235
|
+
avgColor = $avg
|
|
236
|
+
edgeDensity = $edgeDensity
|
|
237
|
+
changed = $changed
|
|
238
|
+
grid = "$bw x $bh"
|
|
239
|
+
}
|
|
240
|
+
if ($pngPath) { $res.png = $pngPath }
|
|
241
|
+
|
|
242
|
+
$img.Dispose(); $full.Dispose(); $small.Dispose()
|
|
243
|
+
|
|
244
|
+
$res | ConvertTo-Json -Compress
|
|
245
|
+
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$Crop = '',
|
|
3
|
+
[string]$File = '',
|
|
4
|
+
[int]$S1 = 3,
|
|
5
|
+
[int]$S2 = 4
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
10
|
+
|
|
11
|
+
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
12
|
+
Add-Type -AssemblyName System.Drawing
|
|
13
|
+
|
|
14
|
+
$tmpDir = if ($env:DSH_LOOK_DATA) { $env:DSH_LOOK_DATA } else { Join-Path $env:TEMP 'dsh-look' }
|
|
15
|
+
if (-not (Test-Path $tmpDir)) { New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null }
|
|
16
|
+
|
|
17
|
+
$src = $File
|
|
18
|
+
if ($Crop -ne '') {
|
|
19
|
+
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
|
|
20
|
+
$dst = Join-Path $tmpDir "read-$stamp.png"
|
|
21
|
+
if ($File -ne '' -and (Test-Path $File)) {
|
|
22
|
+
# 已经有图:从这张图上裁(窗口截图 + 相对窗口的 crop 走这一条)
|
|
23
|
+
$im = [System.Drawing.Bitmap]::FromFile($File)
|
|
24
|
+
try {
|
|
25
|
+
$c = $Crop -split ','
|
|
26
|
+
if ($c.Length -eq 4) {
|
|
27
|
+
$rx = [Math]::Max(0, [int]$c[0]); $ry = [Math]::Max(0, [int]$c[1])
|
|
28
|
+
$rw = [int]$c[2]; $rh = [int]$c[3]
|
|
29
|
+
if ($rx -ge $im.Width) { $rx = 0 }
|
|
30
|
+
if ($ry -ge $im.Height) { $ry = 0 }
|
|
31
|
+
if ($rx + $rw -gt $im.Width) { $rw = $im.Width - $rx }
|
|
32
|
+
if ($ry + $rh -gt $im.Height) { $rh = $im.Height - $ry }
|
|
33
|
+
if ($rw -ge 1 -and $rh -ge 1) {
|
|
34
|
+
$rect = New-Object System.Drawing.Rectangle $rx, $ry, $rw, $rh
|
|
35
|
+
$bmp = $im.Clone($rect, $im.PixelFormat)
|
|
36
|
+
$bmp.Save($dst, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
37
|
+
$bmp.Dispose()
|
|
38
|
+
$src = $dst
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
finally { $im.Dispose() }
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
# 没有现成的图:整屏抓一块
|
|
46
|
+
$look = Join-Path $PSScriptRoot 'look.ps1'
|
|
47
|
+
$larg = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $look, '-Crop', $Crop, '-Image', '-Out', $dst)
|
|
48
|
+
& powershell.exe @larg | Out-Null
|
|
49
|
+
if (Test-Path $dst) { $src = $dst }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (-not (Test-Path $src)) { '{"ok":false,"error":"no image"}'; exit 1 }
|
|
53
|
+
|
|
54
|
+
$asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {
|
|
55
|
+
$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and
|
|
56
|
+
$_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1'
|
|
57
|
+
})[0]
|
|
58
|
+
function Await($op, $type) {
|
|
59
|
+
$t = $asTask.MakeGenericMethod($type).Invoke($null, @($op))
|
|
60
|
+
$t.Wait()
|
|
61
|
+
return $t.Result
|
|
62
|
+
}
|
|
63
|
+
[Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime] | Out-Null
|
|
64
|
+
[Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics.Imaging, ContentType = WindowsRuntime] | Out-Null
|
|
65
|
+
[Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType = WindowsRuntime] | Out-Null
|
|
66
|
+
|
|
67
|
+
$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
|
|
68
|
+
if ($null -eq $engine) { '{"ok":false,"error":"no ocr engine"}'; exit 1 }
|
|
69
|
+
|
|
70
|
+
$base = [System.Drawing.Image]::FromFile($src)
|
|
71
|
+
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
72
|
+
|
|
73
|
+
function Join-OcrWords($words) {
|
|
74
|
+
# Windows OCR 把中文的每个字当一个 word,直接拼 $_.Text 会变成「新 会 话」。
|
|
75
|
+
# 按字框之间的空隙判:挨得紧就连着写,明显分开才补一个空格。
|
|
76
|
+
$sb = New-Object System.Text.StringBuilder
|
|
77
|
+
$prev = $null
|
|
78
|
+
foreach ($w in $words) {
|
|
79
|
+
$t = $w.Text
|
|
80
|
+
if ([string]::IsNullOrEmpty($t)) { continue }
|
|
81
|
+
if ($null -ne $prev) {
|
|
82
|
+
$gap = $w.BoundingRect.X - ($prev.BoundingRect.X + $prev.BoundingRect.Width)
|
|
83
|
+
$hh = [Math]::Max($w.BoundingRect.Height, $prev.BoundingRect.Height)
|
|
84
|
+
if ($gap -gt ($hh * 0.20)) { [void]$sb.Append(' ') }
|
|
85
|
+
}
|
|
86
|
+
[void]$sb.Append($t)
|
|
87
|
+
$prev = $w
|
|
88
|
+
}
|
|
89
|
+
return $sb.ToString()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function Ocr-Scaled($img, [int]$scale, [string]$tag) {
|
|
93
|
+
$bw = $img.Width * $scale
|
|
94
|
+
$bh = $img.Height * $scale
|
|
95
|
+
$b = New-Object System.Drawing.Bitmap $bw, $bh
|
|
96
|
+
$g = [System.Drawing.Graphics]::FromImage($b)
|
|
97
|
+
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
98
|
+
$g.DrawImage($img, 0, 0, $bw, $bh)
|
|
99
|
+
$g.Dispose()
|
|
100
|
+
$png = Join-Path $tmpDir ("ocr-" + $tag + ".png")
|
|
101
|
+
$b.Save($png, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
102
|
+
$b.Dispose()
|
|
103
|
+
|
|
104
|
+
$f = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($png)) ([Windows.Storage.StorageFile])
|
|
105
|
+
$st = Await ($f.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
|
|
106
|
+
$dec = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($st)) ([Windows.Graphics.Imaging.BitmapDecoder])
|
|
107
|
+
$sb = Await ($dec.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
|
|
108
|
+
$r = Await ($engine.RecognizeAsync($sb)) ([Windows.Media.Ocr.OcrResult])
|
|
109
|
+
$st.Dispose()
|
|
110
|
+
return @($r.Lines | ForEach-Object { Join-OcrWords $_.Words })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Windows OCR 有单边像素上限(OcrEngine.MaxImageDimension),整屏放大 3x/4x 会超限
|
|
114
|
+
$maxDim = 10000
|
|
115
|
+
try { $maxDim = [int][Windows.Media.Ocr.OcrEngine]::MaxImageDimension } catch {}
|
|
116
|
+
if ($maxDim -le 0) { $maxDim = 10000 }
|
|
117
|
+
|
|
118
|
+
# 图本身比上限还大时先整体缩小(否则连 1x 都过不了)
|
|
119
|
+
$longSide = [Math]::Max($base.Width, $base.Height)
|
|
120
|
+
$capped = $false
|
|
121
|
+
if ($longSide -gt $maxDim) {
|
|
122
|
+
$f = $maxDim / $longSide
|
|
123
|
+
$nw = [int][Math]::Max(1, [Math]::Floor($base.Width * $f))
|
|
124
|
+
$nh = [int][Math]::Max(1, [Math]::Floor($base.Height * $f))
|
|
125
|
+
$small = New-Object System.Drawing.Bitmap $nw, $nh
|
|
126
|
+
$gs = [System.Drawing.Graphics]::FromImage($small)
|
|
127
|
+
$gs.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
128
|
+
$gs.DrawImage($base, 0, 0, $nw, $nh)
|
|
129
|
+
$gs.Dispose()
|
|
130
|
+
$base.Dispose()
|
|
131
|
+
$base = $small
|
|
132
|
+
$capped = $true
|
|
133
|
+
$longSide = [Math]::Max($nw, $nh)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
$cap = [int][Math]::Floor($maxDim / [Math]::Max(1, $longSide))
|
|
137
|
+
if ($cap -lt 1) { $cap = 1 }
|
|
138
|
+
$s1 = [Math]::Min($S1, $cap); if ($s1 -lt 1) { $s1 = 1 }
|
|
139
|
+
$s2 = [Math]::Min($S2, $cap); if ($s2 -lt 1) { $s2 = 1 }
|
|
140
|
+
# 两轮要不同倍率才有比对意义
|
|
141
|
+
if ($s1 -eq $s2 -and $s2 -gt 1) { $s1 = $s2 - 1 }
|
|
142
|
+
|
|
143
|
+
$h1 = @(Ocr-Scaled $base $s1 "a")
|
|
144
|
+
$h2 = @(Ocr-Scaled $base $s2 "b")
|
|
145
|
+
$base.Dispose()
|
|
146
|
+
$sw.Stop()
|
|
147
|
+
|
|
148
|
+
$A = @($h1 | Where-Object { $_.Trim() -ne '' })
|
|
149
|
+
$B = @($h2 | Where-Object { $_.Trim() -ne '' })
|
|
150
|
+
|
|
151
|
+
function Sim($x, $y) {
|
|
152
|
+
if ($x -eq $y) { return 1.0 }
|
|
153
|
+
$cx = @{}; foreach ($c in $x.ToCharArray()) { $cx[$c] = 1 + ($cx[$c] -as [int]) }
|
|
154
|
+
$used = 0
|
|
155
|
+
foreach ($c in $y.ToCharArray()) { if (($cx[$c] -as [int]) -gt 0) { $cx[$c] = $cx[$c] - 1; $used++ } }
|
|
156
|
+
$max = [Math]::Max($x.Length, $y.Length)
|
|
157
|
+
if ($max -eq 0) { return 1.0 }
|
|
158
|
+
return [double]$used / $max
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
$items = New-Object System.Collections.ArrayList
|
|
162
|
+
$usedB = New-Object 'bool[]' $B.Count
|
|
163
|
+
foreach ($a in $A) {
|
|
164
|
+
$best = -1; $bestS = 0.0
|
|
165
|
+
for ($j = 0; $j -lt $B.Count; $j++) {
|
|
166
|
+
if ($usedB[$j]) { continue }
|
|
167
|
+
$s = Sim $a $B[$j]
|
|
168
|
+
if ($s -gt $bestS) { $bestS = $s; $best = $j }
|
|
169
|
+
}
|
|
170
|
+
if ($best -ge 0 -and $bestS -ge 0.6) {
|
|
171
|
+
$usedB[$best] = $true
|
|
172
|
+
if ($B[$best] -eq $a) {
|
|
173
|
+
[void]$items.Add(@{ text = $a; consistent = $true })
|
|
174
|
+
} else {
|
|
175
|
+
$x = $a; $y = $B[$best]
|
|
176
|
+
if ($y.Length -gt $x.Length) { $x = $B[$best]; $y = $a }
|
|
177
|
+
[void]$items.Add(@{ text = $x; alt = $y; consistent = $false })
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
[void]$items.Add(@{ text = $a; alt = $null; consistent = $false; uncertain = $true })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
$chars = 0
|
|
185
|
+
foreach ($it in $items) { $chars += $it.text.Length }
|
|
186
|
+
|
|
187
|
+
$obj = [ordered]@{
|
|
188
|
+
ok = $true
|
|
189
|
+
file = $src
|
|
190
|
+
scales = @($s1, $s2)
|
|
191
|
+
capped = $capped
|
|
192
|
+
lines = $items
|
|
193
|
+
chars = $chars
|
|
194
|
+
disagree = @($items | Where-Object { -not $_.consistent }).Count
|
|
195
|
+
ms = $sw.ElapsedMilliseconds
|
|
196
|
+
}
|
|
197
|
+
$obj | ConvertTo-Json -Depth 6 -Compress
|
|
198
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Spectacles self-test
|
|
2
|
+
# Runs on a machine where the plugin was just installed and reports what works.
|
|
3
|
+
# Usage: powershell -NoProfile -ExecutionPolicy Bypass -File selftest.ps1 [-Json]
|
|
4
|
+
# Exit code 0 = all required checks passed, 1 = something required failed.
|
|
5
|
+
|
|
6
|
+
param([switch]$Json)
|
|
7
|
+
|
|
8
|
+
$ErrorActionPreference = 'Continue'
|
|
9
|
+
$here = $PSScriptRoot
|
|
10
|
+
$dataDir = if ($env:DSH_LOOK_DATA) { $env:DSH_LOOK_DATA } else { Join-Path $env:TEMP 'dsh-look-selftest' }
|
|
11
|
+
if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Force -Path $dataDir | Out-Null }
|
|
12
|
+
|
|
13
|
+
$checks = New-Object System.Collections.ArrayList
|
|
14
|
+
function Add-Check([string]$name, [bool]$ok, [string]$detail, [bool]$required = $true) {
|
|
15
|
+
[void]$checks.Add([ordered]@{ name = $name; ok = $ok; required = $required; detail = $detail })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------- environment
|
|
19
|
+
$os = [Environment]::OSVersion.Version
|
|
20
|
+
Add-Check 'windows' ($os.Major -ge 10) ('Windows ' + $os.Major + '.' + $os.Minor + ' build ' + $os.Build)
|
|
21
|
+
|
|
22
|
+
$psv = $PSVersionTable.PSVersion
|
|
23
|
+
Add-Check 'powershell' ($psv.Major -ge 5) ('PowerShell ' + $psv.ToString())
|
|
24
|
+
|
|
25
|
+
$needed = @('look.ps1', 'look2.ps1', 'look-pick.ps1', 'read-text.ps1', 'look-region.ps1')
|
|
26
|
+
$missing = @()
|
|
27
|
+
foreach ($n in $needed) { if (-not (Test-Path (Join-Path $here $n))) { $missing += $n } }
|
|
28
|
+
$scriptDetail = 'all ' + $needed.Count + ' scripts present'
|
|
29
|
+
if ($missing.Count -gt 0) { $scriptDetail = 'missing: ' + ($missing -join ', ') }
|
|
30
|
+
Add-Check 'scripts' ($missing.Count -eq 0) $scriptDetail
|
|
31
|
+
|
|
32
|
+
$probe = Join-Path $dataDir 'write-probe.txt'
|
|
33
|
+
$writable = $false
|
|
34
|
+
try {
|
|
35
|
+
Set-Content -Path $probe -Value 'ok' -ErrorAction Stop
|
|
36
|
+
$writable = (Test-Path $probe)
|
|
37
|
+
Remove-Item $probe -Force -ErrorAction SilentlyContinue
|
|
38
|
+
} catch { $writable = $false }
|
|
39
|
+
Add-Check 'temp-writable' $writable $dataDir
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------- display
|
|
42
|
+
if (-not ('YM.SelfTest' -as [type])) {
|
|
43
|
+
Add-Type -Namespace YM -Name SelfTest -MemberDefinition @'
|
|
44
|
+
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int n);
|
|
45
|
+
[DllImport("user32.dll")] public static extern uint GetDpiForSystem();
|
|
46
|
+
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
47
|
+
'@
|
|
48
|
+
}
|
|
49
|
+
try { [void][YM.SelfTest]::SetProcessDPIAware() } catch {}
|
|
50
|
+
|
|
51
|
+
$sw = [YM.SelfTest]::GetSystemMetrics(0)
|
|
52
|
+
$sh = [YM.SelfTest]::GetSystemMetrics(1)
|
|
53
|
+
Add-Check 'screen-size' ($sw -gt 0 -and $sh -gt 0) ($sw.ToString() + ' x ' + $sh)
|
|
54
|
+
|
|
55
|
+
$dpi = 96
|
|
56
|
+
try { $dpi = [int][YM.SelfTest]::GetDpiForSystem() } catch {}
|
|
57
|
+
Add-Check 'dpi' ($dpi -ge 96) ($dpi.ToString() + ' dpi (' + [math]::Round($dpi / 96 * 100) + '%)')
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
61
|
+
$mon = [System.Windows.Forms.Screen]::AllScreens.Count
|
|
62
|
+
Add-Check 'monitors' ($mon -ge 1) ($mon.ToString() + ' monitor(s)')
|
|
63
|
+
} catch {
|
|
64
|
+
Add-Check 'monitors' $false ('could not query: ' + $_.Exception.Message) $false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------- full screen
|
|
68
|
+
$full = Join-Path $dataDir 'selftest-full.png'
|
|
69
|
+
$fullOut = & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $here 'look.ps1') -Image -Out $full 2>&1 | Out-String
|
|
70
|
+
$fullOk = $false
|
|
71
|
+
$fullDetail = $fullOut.Trim()
|
|
72
|
+
try {
|
|
73
|
+
$o = $fullOut | ConvertFrom-Json
|
|
74
|
+
$fullOk = [bool]$o.ok -and (Test-Path $full)
|
|
75
|
+
if ($fullOk) { $fullDetail = $o.source + ' ' + $o.screen.w + 'x' + $o.screen.h + ' in ' + $o.captureMs + 'ms' }
|
|
76
|
+
} catch {}
|
|
77
|
+
Add-Check 'fullscreen-capture' $fullOk $fullDetail
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------- window capture
|
|
80
|
+
$winScript = Join-Path $here 'look2.ps1'
|
|
81
|
+
$picked = $null
|
|
82
|
+
try {
|
|
83
|
+
$listOut = & powershell -NoProfile -ExecutionPolicy Bypass -File $winScript -List 2>&1 | Out-String
|
|
84
|
+
$list = $listOut | ConvertFrom-Json
|
|
85
|
+
if ($list.windows) {
|
|
86
|
+
$picked = $list.windows |
|
|
87
|
+
Where-Object { $_.title -and $_.rect.w -gt 200 -and $_.rect.h -gt 120 -and $_.rect.x -gt -1000 } |
|
|
88
|
+
Select-Object -First 1
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
if ($picked) {
|
|
92
|
+
$winPng = Join-Path $dataDir 'selftest-window.png'
|
|
93
|
+
$winOut = & powershell -NoProfile -ExecutionPolicy Bypass -File $winScript -Window $picked.title -Image -Out $winPng 2>&1 | Out-String
|
|
94
|
+
$winOk = $false
|
|
95
|
+
$winDetail = $winOut.Trim()
|
|
96
|
+
try {
|
|
97
|
+
$o = $winOut | ConvertFrom-Json
|
|
98
|
+
$winOk = [bool]$o.ok -and (Test-Path $winPng)
|
|
99
|
+
if ($winOk) { $winDetail = '"' + $picked.title + '" ' + $o.region[2] + 'x' + $o.region[3] + ' via ' + $o.source }
|
|
100
|
+
} catch {}
|
|
101
|
+
Add-Check 'window-capture' $winOk $winDetail
|
|
102
|
+
} else {
|
|
103
|
+
Add-Check 'window-capture' $false 'no capturable window found (open Notepad and rerun)' $false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------- OCR
|
|
107
|
+
if (Test-Path $full) {
|
|
108
|
+
$ocrOut = & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $here 'read-text.ps1') -File $full 2>&1 | Out-String
|
|
109
|
+
$ocrOk = $false
|
|
110
|
+
$ocrDetail = 'no output'
|
|
111
|
+
try {
|
|
112
|
+
$o = $ocrOut | ConvertFrom-Json
|
|
113
|
+
$ocrOk = [bool]$o.ok -and ([int]$o.chars -gt 0)
|
|
114
|
+
$ocrDetail = $o.chars.ToString() + ' chars, ' + $o.disagree + ' uncertain, ' + $o.ms + 'ms'
|
|
115
|
+
} catch { $ocrDetail = $ocrOut.Trim() }
|
|
116
|
+
Add-Check 'ocr' $ocrOk $ocrDetail
|
|
117
|
+
} else {
|
|
118
|
+
Add-Check 'ocr' $false 'skipped: full screen capture failed'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------- optional
|
|
122
|
+
$ff = $env:DSH_LOOK_FFMPEG
|
|
123
|
+
if ($ff) {
|
|
124
|
+
Add-Check 'ffmpeg' (Test-Path $ff) ('DSH_LOOK_FFMPEG=' + $ff) $false
|
|
125
|
+
} else {
|
|
126
|
+
$g = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue
|
|
127
|
+
$ffNote = 'not used (optional)'
|
|
128
|
+
if ($g) { $ffNote = 'found on PATH, not used (optional)' }
|
|
129
|
+
Add-Check 'ffmpeg' $true $ffNote $false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- report
|
|
133
|
+
$failed = @($checks | Where-Object { $_.required -and -not $_.ok })
|
|
134
|
+
$ok = ($failed.Count -eq 0)
|
|
135
|
+
|
|
136
|
+
if ($Json) {
|
|
137
|
+
[ordered]@{ ok = $ok; machine = $env:COMPUTERNAME; checks = $checks; dataDir = $dataDir } | ConvertTo-Json -Depth 5
|
|
138
|
+
} else {
|
|
139
|
+
Write-Output ('Spectacles self-test on ' + $env:COMPUTERNAME)
|
|
140
|
+
Write-Output ''
|
|
141
|
+
foreach ($c in $checks) {
|
|
142
|
+
$mark = '[FAIL]'
|
|
143
|
+
if ($c.ok) { $mark = '[ ok ]' } elseif (-not $c.required) { $mark = '[warn]' }
|
|
144
|
+
Write-Output ($mark + ' ' + $c.name.PadRight(18) + $c.detail)
|
|
145
|
+
}
|
|
146
|
+
Write-Output ''
|
|
147
|
+
if ($ok) { Write-Output 'All required checks passed.' }
|
|
148
|
+
else { Write-Output ('Failed: ' + (($failed | ForEach-Object { $_.name }) -join ', ')) }
|
|
149
|
+
Write-Output ('Artifacts: ' + $dataDir)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if ($ok) { exit 0 } else { exit 1 }
|