dsh-doc-assistant 0.1.14 → 0.1.18

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/src/host/ocr.js CHANGED
@@ -1,253 +1,163 @@
1
- /**
2
- * Recognize text on a page PNG with macOS Vision.
3
- *
4
- * The reader draws only the PNG. These boxes are hit targets: the recognized
5
- * string is returned to the selection menu and is not painted again.
6
- * Coordinates are fractions of the image, origin at the top left, matching
7
- * `pdfText.js`. Other platforms, or a machine without the Swift compiler,
8
- * return an empty line list and the reader keeps the PDF text layer.
9
- *
10
- * @module dsh-doc-assistant/host/ocr
1
+ /** Local page OCR, using macOS Vision or Windows' built-in WinRT OCR.
2
+ * Both implementations return top-left, normalized hit boxes. No text is
3
+ * painted over the page and no document contents leave the machine here.
11
4
  */
12
-
13
- import { spawn, execFile } from 'node:child_process';
14
- import { createHash } from 'node:crypto';
15
- import { access, chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
16
- import { tmpdir } from 'node:os';
17
- import { join } from 'node:path';
18
- import { promisify } from 'node:util';
5
+ import { spawn } from 'node:child_process';
6
+ import { readFile, rename, stat, writeFile } from 'node:fs/promises';
7
+ import { win32 } from 'node:path';
19
8
  import { fileURLToPath } from 'node:url';
20
9
 
21
- const execFileAsync = promisify(execFile);
22
- const SCRIPT = fileURLToPath(new URL('./ocr_vision.swift', import.meta.url));
23
- const MIN_CONFIDENCE = 0.2;
10
+ const MAC_SCRIPT = fileURLToPath(new URL('./ocr_vision.js', import.meta.url));
11
+ const WINDOWS_SCRIPT = fileURLToPath(new URL('./ocr_windows.ps1', import.meta.url));
12
+ const CACHE_VERSION = 2;
24
13
  const MAX_LINES = 500;
25
-
26
- /** @type {Promise<string|null> | null} */
27
- let binaryPromise = null;
28
-
29
- /** @type {Map<string, Promise<{ aspect: number, lines: object[] }>>} */
14
+ const MAX_OUTPUT = 2_000_000;
30
15
  const inflight = new Map();
31
16
 
32
- function clamp01(value) {
33
- const number = Number(value);
34
- if (!Number.isFinite(number)) return 0;
35
- return Math.max(0, Math.min(1, number));
36
- }
37
-
38
- function round4(value) {
39
- return Math.round(value * 10000) / 10000;
40
- }
41
-
42
- function compact(text) {
43
- return String(text || '').replace(/\s+/g, '').toLowerCase();
44
- }
45
-
46
- /** Same words recognized twice, once as a line and once as a tighter box. */
17
+ function clamp01(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }
18
+ function round4(value) { return Math.round(value * 10000) / 10000; }
19
+ function compact(value) { return String(value || '').replace(/\s+/g, '').toLowerCase(); }
47
20
  function sameSpot(a, b) {
48
21
  const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
49
22
  const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
50
- if (ix <= 0 || iy <= 0) return false;
51
23
  const smaller = Math.min(a.w * a.h, b.w * b.h);
52
- return smaller > 0 && (ix * iy) / smaller > 0.6;
24
+ return ix > 0 && iy > 0 && smaller > 0 && ix * iy / smaller > .6;
53
25
  }
54
26
 
55
- /**
56
- * Drop empty, low-confidence, and duplicate boxes. `y` is already the top edge.
57
- *
58
- * @param {unknown} raw
59
- * @returns {Array<{ text: string, x: number, y: number, w: number, h: number, ocr: true }>}
60
- */
27
+ /** Drop empty, invalid, low-confidence and duplicate hit boxes. */
61
28
  export function normalizeOcrLines(raw) {
62
29
  if (!Array.isArray(raw)) return [];
63
- /** @type {Array<{ text: string, x: number, y: number, w: number, h: number, confidence: number }>} */
64
30
  const rows = [];
65
31
  for (const row of raw) {
66
32
  if (!row || typeof row !== 'object') continue;
67
33
  const text = String(row.text || '').replace(/\s+/g, ' ').trim();
68
34
  if (!text || text.length > 400) continue;
35
+ if (![row.x, row.y, row.w, row.h].every(value => Number.isFinite(Number(value)))) continue;
69
36
  const confidence = Number(row.confidence);
70
- if (Number.isFinite(confidence) && confidence < MIN_CONFIDENCE) continue;
71
- const w = clamp01(row.w);
72
- const h = clamp01(row.h);
73
- if (w < 0.003 || h < 0.003) continue;
74
- rows.push({
75
- text,
76
- x: round4(clamp01(row.x)),
77
- y: round4(clamp01(row.y)),
78
- w: round4(w),
79
- h: round4(h),
80
- confidence: Number.isFinite(confidence) ? confidence : 1,
81
- });
37
+ if (Number.isFinite(confidence) && confidence < .2) continue;
38
+ const x = clamp01(row.x), y = clamp01(row.y);
39
+ const w = Math.min(clamp01(row.w), 1 - x), h = Math.min(clamp01(row.h), 1 - y);
40
+ if (w < .003 || h < .003) continue;
41
+ rows.push({ text, x: round4(x), y: round4(y), w: round4(w), h: round4(h),
42
+ confidence: Number.isFinite(confidence) ? confidence : 1 });
82
43
  }
83
- rows.sort((a, b) => b.confidence - a.confidence || (b.w * b.h) - (a.w * a.h));
84
- /** @type {typeof rows} */
44
+ rows.sort((a, b) => b.confidence - a.confidence || b.w * b.h - a.w * a.h);
85
45
  const kept = [];
86
46
  for (const row of rows) {
87
- const key = compact(row.text);
88
- const duplicate = kept.some((other) => compact(other.text) === key && sameSpot(row, other));
89
- if (!duplicate) kept.push(row);
47
+ if (!kept.some(other => compact(other.text) === compact(row.text) && sameSpot(row, other))) kept.push(row);
90
48
  }
91
49
  kept.sort((a, b) => a.y - b.y || a.x - b.x);
92
- if (kept.length > MAX_LINES) kept.length = MAX_LINES;
93
- return kept.map((row) => ({
94
- text: row.text,
95
- x: row.x,
96
- y: row.y,
97
- w: row.w,
98
- h: row.h,
99
- ocr: true,
100
- }));
101
- }
102
-
103
- async function scriptHash() {
104
- const source = await readFile(SCRIPT);
105
- return createHash('sha256').update(source).digest('hex').slice(0, 16);
106
- }
107
-
108
- async function compileBinary(home) {
109
- if (process.platform !== 'darwin') return null;
110
- const hash = await scriptHash();
111
- const dir = join(home, 'bin');
112
- const dest = join(dir, `ocr-vision-${hash}`);
113
- try {
114
- await access(dest);
115
- return dest;
116
- } catch {
117
- // Compile once, then reuse the binary.
118
- }
119
- await mkdir(dir, { recursive: true });
120
- const tmp = join(tmpdir(), `dsh-ocr-vision-${hash}`);
121
- try {
122
- await execFileAsync('xcrun', ['swiftc', '-O', '-o', tmp, SCRIPT], {
123
- timeout: 180_000,
124
- maxBuffer: 4_000_000,
125
- });
126
- } catch (error) {
127
- const message = error instanceof Error ? error.message : String(error);
128
- console.error(`[dsh-doc-assistant] 页面文字识别不可用:${message.split('\n')[0]}`);
129
- return null;
130
- }
131
- await chmod(tmp, 0o755);
132
- await rename(tmp, dest);
133
- return dest;
50
+ return kept.slice(0, MAX_LINES).map(({ confidence, ...row }) => ({ ...row, ocr: true }));
134
51
  }
135
52
 
136
- function ensureBinary(home) {
137
- if (process.platform !== 'darwin') return Promise.resolve(null);
138
- if (!binaryPromise) {
139
- binaryPromise = compileBinary(home).catch((error) => {
140
- const message = error instanceof Error ? error.message : String(error);
141
- console.error(`[dsh-doc-assistant] 页面文字识别不可用:${message.split('\n')[0]}`);
142
- return null;
143
- });
144
- }
145
- return binaryPromise;
53
+ /** Exported so platform launch/argument handling can be checked on any OS. */
54
+ export function nativeOcrCommand(pngPath, platform = process.platform) {
55
+ if (platform === 'darwin') return { command: '/usr/bin/osascript',
56
+ args: ['-l', 'JavaScript', MAC_SCRIPT, pngPath], engine: 'macos-vision' };
57
+ if (platform === 'win32') return {
58
+ command: win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
59
+ args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', WINDOWS_SCRIPT, '-ImagePath', pngPath],
60
+ engine: 'windows-ocr' };
61
+ return null;
146
62
  }
147
63
 
148
- function runBinary(bin, pngPath) {
149
- return new Promise((resolve) => {
150
- let child;
151
- try {
152
- child = spawn(bin, [pngPath], { stdio: ['ignore', 'pipe', 'pipe'] });
153
- } catch {
154
- resolve(null);
155
- return;
156
- }
64
+ function runNative(command, args) {
65
+ return new Promise(resolve => {
66
+ let child, done = false, size = 0;
157
67
  const out = [];
158
- const timer = setTimeout(() => {
159
- child.kill('SIGKILL');
160
- }, 60_000);
161
- child.stdout.on('data', (chunk) => out.push(chunk));
162
- child.on('error', () => {
163
- clearTimeout(timer);
164
- resolve(null);
68
+ const finish = value => {
69
+ if (done) return;
70
+ done = true; clearTimeout(timer); resolve(value);
71
+ };
72
+ const timer = setTimeout(() => { child?.kill(); finish({ ok: false, code: 'timeout' }); }, 60_000);
73
+ try { child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); }
74
+ catch { finish({ ok: false, code: 'unavailable' }); return; }
75
+ child.stdout.on('data', chunk => {
76
+ size += chunk.length;
77
+ if (size > MAX_OUTPUT) { child.kill(); finish({ ok: false, code: 'output' }); }
78
+ else out.push(chunk);
165
79
  });
80
+ child.stderr.on('data', () => {});
81
+ child.on('error', () => finish({ ok: false, code: 'unavailable' }));
166
82
  child.on('close', () => {
167
- clearTimeout(timer);
168
- const text = Buffer.concat(out).toString('utf8').trim();
169
- try {
170
- const json = JSON.parse(text);
171
- resolve(json && json.ok === true ? json : null);
172
- } catch {
173
- resolve(null);
174
- }
83
+ try { finish(JSON.parse(Buffer.concat(out).toString('utf8').replace(/^\uFEFF/, '').trim())); }
84
+ catch { finish({ ok: false, code: 'unavailable' }); }
175
85
  });
176
86
  });
177
87
  }
178
88
 
179
- async function readCache(cachePath, pngStat) {
89
+ function errorMessage(code, engine) {
90
+ if (code === 'language') return 'Windows 未安装 OCR 语言:请在「设置 → 时间和语言 → 语言和区域」添加中文(简体)的文字识别组件。';
91
+ if (code === 'timeout') return '这一页文字识别超时,请重试或使用支持图片的模型提问。';
92
+ if (code === 'image') return '无法读取这一页的图片。';
93
+ if (code === 'recognition') return '这一页文字识别失败,请重试或使用支持图片的模型提问。';
94
+ if (engine === 'windows-ocr') return 'Windows 系统文字识别不可用,请检查 OCR 语言组件,或使用支持图片的模型提问。';
95
+ if (engine === 'macos-vision') return 'macOS 系统文字识别不可用,请重试或使用支持图片的模型提问。';
96
+ return '当前系统没有可用的本地文字识别,请使用支持图片的模型提问。';
97
+ }
98
+
99
+ async function cachedResult(cachePath, info) {
180
100
  try {
181
- const cached = JSON.parse(await readFile(cachePath, 'utf8'));
182
- if (!cached || cached.ok !== true || !Array.isArray(cached.lines)) return null;
183
- if (cached.pngSize !== pngStat.size) return null;
184
- if (Number(cached.pngMtime) !== Math.round(pngStat.mtimeMs)) return null;
185
- return cached;
186
- } catch {
187
- return null;
188
- }
101
+ const result = JSON.parse(await readFile(cachePath, 'utf8'));
102
+ if (result?.ok !== true || result.version !== CACHE_VERSION || !Array.isArray(result.lines) ||
103
+ result.pngSize !== info.size || result.pngMtime !== Math.round(info.mtimeMs)) return null;
104
+ return result;
105
+ } catch { return null; }
189
106
  }
190
107
 
191
- /**
192
- * OCR one cached page PNG. Failures are an empty list, never a throw.
193
- *
194
- * @param {string} pngPath
195
- * @param {string} home plugin data directory
196
- * @returns {Promise<{ aspect: number, lines: object[] }>}
197
- */
198
- export async function recognizePage(pngPath, home) {
199
- const empty = { aspect: 0, lines: [] };
200
- if (process.platform !== 'darwin') return empty;
201
- const pending = inflight.get(pngPath);
202
- if (pending) return pending;
203
- const job = recognizeUncached(pngPath, home).finally(() => {
204
- if (inflight.get(pngPath) === job) inflight.delete(pngPath);
205
- });
206
- inflight.set(pngPath, job);
207
- return job;
108
+ function pageResult(raw, engine, cached = false) {
109
+ const lines = normalizeOcrLines(raw.lines);
110
+ const width = Number(raw.width) || 0, height = Number(raw.height) || 0;
111
+ const language = typeof raw.language === 'string' ? raw.language.slice(0, 80) : '';
112
+ const warning = engine === 'windows-ocr' && language && !/^zh\b/i.test(language)
113
+ ? '系统没有中文 OCR 语言,中文识别可能不完整;可添加中文文字识别组件或使用支持图片的模型。' : '';
114
+ return { aspect: width > 0 && height > 0 ? width / height : 0, lines,
115
+ ocr: { status: lines.length ? 'ready' : 'empty', engine, language, cached, ...(warning ? { warning, code: 'missing-chinese' } : {}) } };
208
116
  }
209
117
 
210
- async function recognizeUncached(pngPath, home) {
211
- const empty = { aspect: 0, lines: [] };
212
- let pngStat;
118
+ /** Cache-only access for chat indexing. Never starts native OCR or compilation. */
119
+ export async function cachedPageRecognition(imagePath) {
213
120
  try {
214
- pngStat = await stat(pngPath);
215
- } catch {
216
- return empty;
217
- }
218
- if (!pngStat.isFile() || pngStat.size < 32) return empty;
219
- const cachePath = `${pngPath}.ocr.json`;
220
- const cached = await readCache(cachePath, pngStat);
221
- if (cached) {
222
- const width = Number(cached.width) || 0;
223
- const height = Number(cached.height) || 0;
224
- return {
225
- aspect: width > 0 && height > 0 ? width / height : 0,
226
- lines: normalizeOcrLines(cached.lines),
227
- };
228
- }
229
- const bin = await ensureBinary(home);
230
- if (!bin) return empty;
231
- const result = await runBinary(bin, pngPath);
232
- if (!result) return empty;
233
- const lines = normalizeOcrLines(result.lines);
234
- const width = Number(result.width) || 0;
235
- const height = Number(result.height) || 0;
236
- const payload = {
237
- ok: true,
238
- pngSize: pngStat.size,
239
- pngMtime: Math.round(pngStat.mtimeMs),
240
- width,
241
- height,
242
- lines,
243
- };
244
- try {
245
- await writeFile(cachePath, JSON.stringify(payload));
246
- } catch {
247
- // The page can still be selected; the next open recognizes it again.
248
- }
249
- return {
250
- aspect: width > 0 && height > 0 ? width / height : 0,
251
- lines,
252
- };
121
+ const info = await stat(imagePath);
122
+ const cached = await cachedResult(`${imagePath}.ocr.json`, info);
123
+ return cached ? pageResult(cached, cached.engine || 'local-ocr', true) : null;
124
+ } catch { return null; }
125
+ }
126
+
127
+ /** Recognize one cached raster. Failed runs are reported and never cached.
128
+ * `home` stays in the signature for callers of previous releases. A test or
129
+ * platform adapter may provide `platform`/`run`; production uses native APIs.
130
+ */
131
+ export async function recognizePage(pngPath, _home, options = {}) {
132
+ const descriptor = nativeOcrCommand(pngPath, options.platform);
133
+ const engine = descriptor?.engine || 'unavailable';
134
+ const empty = (code = 'unavailable') => ({ aspect: 0, lines: [],
135
+ ocr: { status: code === 'unavailable' || code === 'language' ? 'unavailable' : 'error', engine,
136
+ code, error: errorMessage(code, engine) } });
137
+ if (!descriptor) return empty();
138
+ const key = `${engine}\0${pngPath}`;
139
+ if (inflight.has(key)) return inflight.get(key);
140
+ const job = (async () => {
141
+ let info;
142
+ try { info = await stat(pngPath); } catch { return empty('image'); }
143
+ if (!info.isFile() || info.size < 32) return empty('image');
144
+ const cachePath = `${pngPath}.ocr.json`;
145
+ const cached = await cachedResult(cachePath, info);
146
+ if (cached) return pageResult(cached, cached.engine || engine, true);
147
+ let raw;
148
+ try { raw = await (options.run || runNative)(descriptor.command, descriptor.args); }
149
+ catch { return empty(); }
150
+ if (!raw || raw.ok !== true) return empty(raw?.code || 'unavailable');
151
+ if (!(Number(raw.width) > 0 && Number(raw.height) > 0)) return empty('image');
152
+ const result = pageResult(raw, engine);
153
+ const payload = { ok: true, version: CACHE_VERSION, pngSize: info.size,
154
+ pngMtime: Math.round(info.mtimeMs), engine, width: Number(raw.width) || 0,
155
+ height: Number(raw.height) || 0, language: result.ocr.language, lines: result.lines };
156
+ const temp = `${cachePath}.${process.pid}.tmp`;
157
+ try { await writeFile(temp, JSON.stringify(payload)); await rename(temp, cachePath); }
158
+ catch { /* A read-only cache directory must not prevent selecting a page. */ }
159
+ return result;
160
+ })().finally(() => { if (inflight.get(key) === job) inflight.delete(key); });
161
+ inflight.set(key, job);
162
+ return job;
253
163
  }
@@ -0,0 +1,39 @@
1
+ // Run by macOS /usr/bin/osascript -l JavaScript. Vision is provided by macOS;
2
+ // unlike the Swift helper this path does not require Xcode command-line tools.
3
+ ObjC.import('AppKit');
4
+ ObjC.import('Foundation');
5
+ ObjC.import('Vision');
6
+
7
+ function run(argv) {
8
+ try {
9
+ var data = $.NSData.dataWithContentsOfFile(argv[0]);
10
+ var bitmap = $.NSBitmapImageRep.imageRepWithData(data);
11
+ if (!bitmap) return JSON.stringify({ ok: false, code: 'image', lines: [] });
12
+ var request = $.VNRecognizeTextRequest.alloc.init;
13
+ request.recognitionLevel = 0; // VNRequestTextRecognitionLevelAccurate
14
+ request.usesLanguageCorrection = true;
15
+ request.minimumTextHeight = 0.008;
16
+ request.recognitionLanguages = $(['zh-Hans', 'en-US']);
17
+ var handler = $.VNImageRequestHandler.alloc.initWithCGImageOptions(bitmap.CGImage, $({}));
18
+ var error = Ref();
19
+ if (!handler.performRequestsError($([request]), error)) {
20
+ return JSON.stringify({ ok: false, code: 'recognition', lines: [] });
21
+ }
22
+ var results = request.results;
23
+ var lines = [];
24
+ for (var i = 0; i < Number(results.count); i += 1) {
25
+ var observation = results.objectAtIndex(i);
26
+ var candidates = observation.topCandidates(1);
27
+ if (Number(candidates.count) === 0) continue;
28
+ var best = candidates.objectAtIndex(0);
29
+ var box = observation.boundingBox;
30
+ lines.push({ text: ObjC.unwrap(best.string), x: Number(box.origin.x),
31
+ y: 1 - Number(box.origin.y) - Number(box.size.height),
32
+ w: Number(box.size.width), h: Number(box.size.height), confidence: Number(best.confidence) });
33
+ }
34
+ return JSON.stringify({ ok: true, width: Number(bitmap.pixelsWide),
35
+ height: Number(bitmap.pixelsHigh), language: 'zh-Hans,en-US', lines: lines });
36
+ } catch (error) {
37
+ return JSON.stringify({ ok: false, code: 'unavailable', lines: [] });
38
+ }
39
+ }
@@ -0,0 +1,89 @@
1
+ # Windows PowerShell 5.1 provides WinRT interop without a separately installed
2
+ # OCR application. Keep the source ASCII so PowerShell's legacy parser reads it
3
+ # identically on Windows machines with different system code pages.
4
+ param([Parameter(Mandatory=$true)][string]$ImagePath)
5
+ $ErrorActionPreference = 'Stop'
6
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
7
+ $ProgressPreference = 'SilentlyContinue'
8
+
9
+ try {
10
+ Add-Type -AssemblyName System.Runtime.WindowsRuntime
11
+ $null = [Windows.Storage.StorageFile, Windows.Storage, ContentType=WindowsRuntime]
12
+ $null = [Windows.Storage.Streams.IRandomAccessStream, Windows.Storage.Streams, ContentType=WindowsRuntime]
13
+ $null = [Windows.Storage.FileAccessMode, Windows.Storage, ContentType=WindowsRuntime]
14
+ $null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
15
+ $null = [Windows.Graphics.Imaging.BitmapTransform, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
16
+ $null = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
17
+ $null = [Windows.Graphics.Imaging.BitmapPixelFormat, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
18
+ $null = [Windows.Graphics.Imaging.BitmapAlphaMode, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
19
+ $null = [Windows.Graphics.Imaging.ExifOrientationMode, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
20
+ $null = [Windows.Graphics.Imaging.ColorManagementMode, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
21
+ $null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType=WindowsRuntime]
22
+ $null = [Windows.Media.Ocr.OcrResult, Windows.Foundation, ContentType=WindowsRuntime]
23
+ $null = [Windows.Globalization.Language, Windows.Globalization, ContentType=WindowsRuntime]
24
+
25
+ $asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {
26
+ $_.Name -eq 'AsTask' -and $_.IsGenericMethodDefinition -and
27
+ $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1'
28
+ } | Select-Object -First 1
29
+ function Await($operation, [Type]$type) {
30
+ $task = $asTask.MakeGenericMethod($type).Invoke($null, @($operation))
31
+ $task.GetAwaiter().GetResult()
32
+ }
33
+
34
+ $engines = @()
35
+ foreach ($tag in @('zh-Hans', 'en-US')) {
36
+ $language = New-Object Windows.Globalization.Language($tag)
37
+ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromLanguage($language)
38
+ if ($null -ne $engine) { $engines += $engine }
39
+ }
40
+ if ($engines.Count -eq 0) {
41
+ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
42
+ if ($null -ne $engine) { $engines += $engine }
43
+ }
44
+ if ($engines.Count -eq 0) {
45
+ @{ ok=$false; code='language'; lines=@() } | ConvertTo-Json -Depth 8 -Compress
46
+ exit 0
47
+ }
48
+
49
+ $file = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($ImagePath)) ([Windows.Storage.StorageFile])
50
+ $stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
51
+ try {
52
+ $decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])
53
+ $transform = New-Object Windows.Graphics.Imaging.BitmapTransform
54
+ $max = [Windows.Media.Ocr.OcrEngine]::MaxImageDimension
55
+ $scale = [Math]::Min(1.0, $max / [double][Math]::Max($decoder.PixelWidth, $decoder.PixelHeight))
56
+ $transform.ScaledWidth = [uint32][Math]::Max(1, [Math]::Floor($decoder.PixelWidth * $scale))
57
+ $transform.ScaledHeight = [uint32][Math]::Max(1, [Math]::Floor($decoder.PixelHeight * $scale))
58
+ $bitmap = Await ($decoder.GetSoftwareBitmapAsync(
59
+ [Windows.Graphics.Imaging.BitmapPixelFormat]::Bgra8,
60
+ [Windows.Graphics.Imaging.BitmapAlphaMode]::Premultiplied, $transform,
61
+ [Windows.Graphics.Imaging.ExifOrientationMode]::IgnoreExifOrientation,
62
+ [Windows.Graphics.Imaging.ColorManagementMode]::DoNotColorManage)) ([Windows.Graphics.Imaging.SoftwareBitmap])
63
+ try {
64
+ $lines = @()
65
+ # Chinese recognizers also handle Latin slide labels. Use the first
66
+ # available engine to avoid overlaying two different transcriptions.
67
+ $engine = $engines[0]
68
+ $result = Await ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])
69
+ foreach ($line in $result.Lines) {
70
+ $minX = [double]::MaxValue; $minY = [double]::MaxValue
71
+ $maxX = 0.0; $maxY = 0.0
72
+ foreach ($word in $line.Words) {
73
+ $rect = $word.BoundingRect
74
+ $minX = [Math]::Min($minX, $rect.X); $minY = [Math]::Min($minY, $rect.Y)
75
+ $maxX = [Math]::Max($maxX, $rect.X + $rect.Width)
76
+ $maxY = [Math]::Max($maxY, $rect.Y + $rect.Height)
77
+ }
78
+ if ($maxX -gt $minX -and $maxY -gt $minY) {
79
+ $lines += @{ text=$line.Text; x=$minX / $bitmap.PixelWidth; y=$minY / $bitmap.PixelHeight;
80
+ w=($maxX - $minX) / $bitmap.PixelWidth; h=($maxY - $minY) / $bitmap.PixelHeight; confidence=1 }
81
+ }
82
+ }
83
+ @{ ok=$true; width=$bitmap.PixelWidth; height=$bitmap.PixelHeight;
84
+ language=$engine.RecognizerLanguage.LanguageTag; lines=@($lines) } | ConvertTo-Json -Depth 8 -Compress
85
+ } finally { if ($null -ne $bitmap) { $bitmap.Dispose() } }
86
+ } finally { if ($null -ne $stream) { $stream.Dispose() } }
87
+ } catch {
88
+ @{ ok=$false; code='unavailable'; lines=@() } | ConvertTo-Json -Depth 8 -Compress
89
+ }
@@ -114,7 +114,9 @@ function runTool(args, payload, timeoutMs) {
114
114
  const message = json && typeof json.error === 'string' && json.error
115
115
  ? json.error
116
116
  : '文档处理失败';
117
- finish(new Error(message));
117
+ const failure = new Error(message);
118
+ if (typeof json?.code === 'string' && /^WORD_LAYOUT_/.test(json.code)) failure.code = json.code;
119
+ finish(failure);
118
120
  return;
119
121
  }
120
122
  finish(null);