@lyra_wang/dsh-image-ocr 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +36 -2
- package/client.js +282 -0
- package/cordis.patch.yml +3 -0
- package/index.js +159 -0
- package/ocr.swift +44 -0
- package/package.json +49 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wang Ying (LyraWang)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# image-ocr
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**在 DSH 输入框点「+ 识图」,把剪贴板里截图上的文字识别出来,直接插入草稿。**
|
|
4
|
+
|
|
5
|
+
截图(⌃⌘⇧4 或任意截图工具)→ 点「+ 识图」→ 文字出现在输入框,接着问 agent。
|
|
6
|
+
|
|
7
|
+
## 原理
|
|
8
|
+
|
|
9
|
+
- 用 macOS 自带 **Vision** 框架做 OCR(离线、免费、中文支持好);
|
|
10
|
+
- 插件带一份 `ocr.swift`,首次使用时用 `xcrun swiftc` 编译,缓存到
|
|
11
|
+
`~/Library/Application Support/dsh-image-ocr/ocr`;
|
|
12
|
+
- 之后每次识别约 **0.6–1 秒**(实测整屏截图 53 行 0.66s);
|
|
13
|
+
- 宿主侧注册一个同源只读路由 `/plugins/image-ocr/ocr`,复用 DSH 连接鉴权。
|
|
14
|
+
|
|
15
|
+
## 使用
|
|
16
|
+
|
|
17
|
+
1. 截图到剪贴板(⌃⌘⇧4 选区域,或 ⌃⌘⇧3 全屏);
|
|
18
|
+
2. 输入框左侧点「+ 识图」打开面板;
|
|
19
|
+
3. 在面板里按 **⌘V 粘贴截图**(也可以把图片文件拖进来);
|
|
20
|
+
4. 面板里显示识别出的文字,可编辑;点「插入输入框」进草稿,或「复制」带走;
|
|
21
|
+
5. 底部还有「直接读剪贴板」按钮,跳过粘贴直接识别剪贴板里的图。
|
|
22
|
+
|
|
23
|
+
第一次识别可能需要等 30–60 秒(在编译 OCR 引擎),之后再点就很快(实测 0.66s)。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
dsh plugin --profile <profile> add @lyra_wang/dsh-image-ocr
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
前置:macOS 13+ 且装有 Xcode Command Line Tools(`xcrun --find swiftc` 有输出即可)。
|
|
32
|
+
|
|
33
|
+
## 边界
|
|
34
|
+
|
|
35
|
+
- 只处理你粘贴/拖入的图片(以及「直接读剪贴板」时的剪贴板图),不读屏幕、不自动截图;
|
|
36
|
+
- 不联网,图片不出本机;
|
|
37
|
+
- 手写体/低分辨率截图识别率有限,建议放大后再截。
|
package/client.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of the image-ocr bundle.
|
|
3
|
+
*
|
|
4
|
+
* One compact button in `conversation.input.left` opens a small panel: paste a
|
|
5
|
+
* screenshot into it (⌘V), the host OCRs the image locally and the recognized
|
|
6
|
+
* text appears inside the panel. From there the text can be inserted into the
|
|
7
|
+
* composer or copied.
|
|
8
|
+
*/
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: '@lyra_wang/dsh-image-ocr',
|
|
11
|
+
factory(require) {
|
|
12
|
+
const React = require('react');
|
|
13
|
+
const h = React.createElement;
|
|
14
|
+
const { useEffect, useRef, useState } = React;
|
|
15
|
+
|
|
16
|
+
const ROOT = 'dsh-image-ocr';
|
|
17
|
+
|
|
18
|
+
const CSS = `
|
|
19
|
+
.${ROOT}-btn{all:unset;cursor:pointer;display:inline-flex;align-items:center;gap:5px;
|
|
20
|
+
height:26px;padding:0 8px;border-radius:7px;color:var(--dsw-alias-label-secondary,#b9b9c0);
|
|
21
|
+
font:12px/1 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;user-select:none}
|
|
22
|
+
.${ROOT}-btn:hover,.${ROOT}-btn[data-open="true"]{background:var(--dsw-alias-bg-layer-2,#3a3a41);
|
|
23
|
+
color:var(--dsw-alias-label-primary,#e6e6e9)}
|
|
24
|
+
.${ROOT}-panel{position:fixed;z-index:2147483000;width:460px;display:flex;flex-direction:column;gap:8px;
|
|
25
|
+
background:var(--dsw-alias-bg-overlay,#2b2b2f);border:1px solid var(--dsw-alias-border-l1,#3a3a40);
|
|
26
|
+
border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.45);padding:10px;
|
|
27
|
+
font:12px/1.45 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;
|
|
28
|
+
color:var(--dsw-alias-label-primary,#e6e6e9)}
|
|
29
|
+
.${ROOT}-title{display:flex;justify-content:space-between;align-items:center;font-size:12px;
|
|
30
|
+
color:var(--dsw-alias-label-secondary,#9a9aa2)}
|
|
31
|
+
.${ROOT}-close{all:unset;cursor:pointer;color:var(--dsw-alias-label-secondary,#9a9aa2);padding:2px 6px;border-radius:6px}
|
|
32
|
+
.${ROOT}-close:hover{background:var(--dsw-alias-bg-layer-2,#3a3a41);color:var(--dsw-alias-label-primary,#e6e6e9)}
|
|
33
|
+
.${ROOT}-drop{min-height:96px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;
|
|
34
|
+
border:1px dashed var(--dsw-alias-border-l1,#4a4a52);border-radius:10px;padding:12px;cursor:text;
|
|
35
|
+
color:var(--dsw-alias-label-secondary,#9a9aa2);text-align:center;outline:none}
|
|
36
|
+
.${ROOT}-drop[data-over="true"]{border-color:#4c8dff;background:rgba(76,141,255,.08)}
|
|
37
|
+
.${ROOT}-drop img{max-width:100%;max-height:120px;border-radius:6px}
|
|
38
|
+
.${ROOT}-textarea{all:unset;box-sizing:border-box;width:100%;min-height:120px;max-height:260px;overflow:auto;
|
|
39
|
+
padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-1,#1f1f23);
|
|
40
|
+
border:1px solid var(--dsw-alias-border-l1,#3a3a40);color:var(--dsw-alias-label-primary,#e6e6e9);
|
|
41
|
+
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;user-select:text}
|
|
42
|
+
.${ROOT}-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
|
43
|
+
.${ROOT}-action{all:unset;cursor:pointer;padding:5px 10px;border-radius:7px;
|
|
44
|
+
background:var(--dsw-alias-bg-layer-2,#3a3a41);color:var(--dsw-alias-label-primary,#e6e6e9);font-size:12px}
|
|
45
|
+
.${ROOT}-action:hover{background:var(--dsw-alias-bg-layer-3,#46464f)}
|
|
46
|
+
.${ROOT}-action[data-primary="true"]{background:#3b6fe0}
|
|
47
|
+
.${ROOT}-action[disabled]{opacity:.5;cursor:not-allowed}
|
|
48
|
+
.${ROOT}-hint{color:var(--dsw-alias-label-secondary,#9a9aa2);font-size:11.5px}
|
|
49
|
+
.${ROOT}-error{color:#ff8a8a;font-size:11.5px}
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
function readImageFromDataTransfer(dataTransfer) {
|
|
53
|
+
if (!dataTransfer) return null;
|
|
54
|
+
const items = Array.from(dataTransfer.items || []);
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
|
|
57
|
+
return item.getAsFile();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const files = Array.from(dataTransfer.files || []);
|
|
61
|
+
return files.find((file) => file.type && file.type.startsWith('image/')) || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fileToDataUrl(file) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const reader = new FileReader();
|
|
67
|
+
reader.onload = () => resolve(String(reader.result || ''));
|
|
68
|
+
reader.onerror = () => reject(new Error('读取图片失败'));
|
|
69
|
+
reader.readAsDataURL(file);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ImageOcr(props) {
|
|
74
|
+
const inputActions = props && props.inputActions;
|
|
75
|
+
const [open, setOpen] = useState(false);
|
|
76
|
+
const [anchor, setAnchor] = useState(null);
|
|
77
|
+
const [preview, setPreview] = useState('');
|
|
78
|
+
const [text, setText] = useState('');
|
|
79
|
+
const [busy, setBusy] = useState(false);
|
|
80
|
+
const [error, setError] = useState('');
|
|
81
|
+
const [over, setOver] = useState(false);
|
|
82
|
+
const btnRef = useRef(null);
|
|
83
|
+
const panelRef = useRef(null);
|
|
84
|
+
const dropRef = useRef(null);
|
|
85
|
+
|
|
86
|
+
const openPanel = () => {
|
|
87
|
+
const rect = btnRef.current ? btnRef.current.getBoundingClientRect() : null;
|
|
88
|
+
if (rect) setAnchor({ left: Math.max(12, rect.left), bottom: Math.max(12, window.innerHeight - rect.top + 8) });
|
|
89
|
+
setOpen(true);
|
|
90
|
+
setError('');
|
|
91
|
+
setTimeout(() => dropRef.current && dropRef.current.focus(), 30);
|
|
92
|
+
};
|
|
93
|
+
const close = () => { setOpen(false); setPreview(''); setText(''); setError(''); };
|
|
94
|
+
|
|
95
|
+
const ocr = async (imageOverride) => {
|
|
96
|
+
setBusy(true);
|
|
97
|
+
setError('');
|
|
98
|
+
setText('');
|
|
99
|
+
try {
|
|
100
|
+
const payloadBody = {};
|
|
101
|
+
if (imageOverride) payloadBody.image = imageOverride;
|
|
102
|
+
const response = await fetch('/plugins/image-ocr/ocr', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'content-type': 'application/json' },
|
|
105
|
+
body: JSON.stringify(payloadBody),
|
|
106
|
+
});
|
|
107
|
+
const payload = await response.json().catch(() => ({}));
|
|
108
|
+
if (!response.ok || payload.ok !== true) throw new Error(payload.message || ('HTTP ' + response.status));
|
|
109
|
+
const value = String(payload.text || '');
|
|
110
|
+
if (value.trim() === '') {
|
|
111
|
+
setError('没有识别到文字(图片清晰度或语言可能不支持)');
|
|
112
|
+
} else {
|
|
113
|
+
setText(value);
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
117
|
+
} finally {
|
|
118
|
+
setBusy(false);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleFile = async (file) => {
|
|
123
|
+
if (!file) return;
|
|
124
|
+
if (!file.type || !file.type.startsWith('image/')) {
|
|
125
|
+
setError('请粘贴图片(PNG / JPEG)');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const dataUrl = await fileToDataUrl(file);
|
|
130
|
+
setPreview(dataUrl);
|
|
131
|
+
await ocr(dataUrl);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const onPaste = (event) => {
|
|
138
|
+
const file = readImageFromDataTransfer(event.clipboardData);
|
|
139
|
+
if (!file) {
|
|
140
|
+
setError('剪贴板里没有图片:先截图(⌃⌘⇧4),再在这里 ⌘V');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
handleFile(file);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const onDrop = (event) => {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
setOver(false);
|
|
150
|
+
const file = readImageFromDataTransfer(event.dataTransfer);
|
|
151
|
+
if (file) handleFile(file);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!open) return undefined;
|
|
156
|
+
const onDown = (event) => {
|
|
157
|
+
const inPanel = panelRef.current && panelRef.current.contains(event.target);
|
|
158
|
+
const inButton = btnRef.current && btnRef.current.contains(event.target);
|
|
159
|
+
if (!inPanel && !inButton) close();
|
|
160
|
+
};
|
|
161
|
+
document.addEventListener('mousedown', onDown, true);
|
|
162
|
+
return () => document.removeEventListener('mousedown', onDown, true);
|
|
163
|
+
}, [open]);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!open) return undefined;
|
|
167
|
+
const onKey = (event) => { if (event.key === 'Escape') close(); };
|
|
168
|
+
document.addEventListener('keydown', onKey, true);
|
|
169
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
170
|
+
}, [open]);
|
|
171
|
+
|
|
172
|
+
const insert = () => {
|
|
173
|
+
if (text.trim() === '') return;
|
|
174
|
+
if (inputActions && typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
|
|
175
|
+
const span = inputActions.captureInsertion();
|
|
176
|
+
inputActions.insertText(text, span);
|
|
177
|
+
close();
|
|
178
|
+
} else if (navigator.clipboard) {
|
|
179
|
+
navigator.clipboard.writeText(text);
|
|
180
|
+
close();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const copy = () => {
|
|
185
|
+
if (text === '') return;
|
|
186
|
+
if (navigator.clipboard) navigator.clipboard.writeText(text);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const button = h('button', {
|
|
190
|
+
key: 'btn',
|
|
191
|
+
ref: btnRef,
|
|
192
|
+
type: 'button',
|
|
193
|
+
className: ROOT + '-btn',
|
|
194
|
+
'data-open': open ? 'true' : 'false',
|
|
195
|
+
title: '粘贴截图并识别文字',
|
|
196
|
+
onClick: () => (open ? close() : openPanel()),
|
|
197
|
+
}, h('span', null, '+'), h('span', null, '识图'));
|
|
198
|
+
|
|
199
|
+
const panel = open && anchor
|
|
200
|
+
? h('div', {
|
|
201
|
+
key: 'panel',
|
|
202
|
+
ref: panelRef,
|
|
203
|
+
className: ROOT + '-panel',
|
|
204
|
+
style: { left: anchor.left + 'px', bottom: anchor.bottom + 'px' },
|
|
205
|
+
}, [
|
|
206
|
+
h('div', { key: 'title', className: ROOT + '-title' }, [
|
|
207
|
+
h('span', { key: 'label' }, '粘贴截图识别文字'),
|
|
208
|
+
h('button', { key: 'x', type: 'button', className: ROOT + '-close', onClick: close }, '✕'),
|
|
209
|
+
]),
|
|
210
|
+
preview === ''
|
|
211
|
+
? h('div', {
|
|
212
|
+
key: 'drop',
|
|
213
|
+
ref: dropRef,
|
|
214
|
+
className: ROOT + '-drop',
|
|
215
|
+
tabIndex: 0,
|
|
216
|
+
'data-over': over ? 'true' : 'false',
|
|
217
|
+
onPaste,
|
|
218
|
+
onDrop,
|
|
219
|
+
onDragOver: (event) => { event.preventDefault(); setOver(true); },
|
|
220
|
+
onDragLeave: () => setOver(false),
|
|
221
|
+
}, [
|
|
222
|
+
h('div', { key: 'a' }, '点这里,然后按 ⌘V 粘贴截图'),
|
|
223
|
+
h('div', { key: 'b', className: ROOT + '-hint' }, '也可以把图片文件拖进来'),
|
|
224
|
+
])
|
|
225
|
+
: h('div', {
|
|
226
|
+
key: 'drop2',
|
|
227
|
+
ref: dropRef,
|
|
228
|
+
className: ROOT + '-drop',
|
|
229
|
+
tabIndex: 0,
|
|
230
|
+
onPaste,
|
|
231
|
+
onDrop,
|
|
232
|
+
onDragOver: (event) => { event.preventDefault(); setOver(true); },
|
|
233
|
+
onDragLeave: () => setOver(false),
|
|
234
|
+
}, [
|
|
235
|
+
h('img', { key: 'img', src: preview, alt: '粘贴的截图' }),
|
|
236
|
+
h('button', {
|
|
237
|
+
key: 'again',
|
|
238
|
+
type: 'button',
|
|
239
|
+
className: ROOT + '-action',
|
|
240
|
+
onClick: () => { setPreview(''); setText(''); setError(''); setTimeout(() => dropRef.current && dropRef.current.focus(), 20); },
|
|
241
|
+
}, '换一张'),
|
|
242
|
+
]),
|
|
243
|
+
busy ? h('div', { key: 'busy', className: ROOT + '-hint' }, '识别中…(第一次可能需要 30–60 秒编译引擎)') : null,
|
|
244
|
+
error !== '' ? h('div', { key: 'err', className: ROOT + '-error' }, error) : null,
|
|
245
|
+
text !== '' || busy
|
|
246
|
+
? h('div', { key: 'out', className: ROOT + '-textarea' }, text)
|
|
247
|
+
: null,
|
|
248
|
+
h('div', { key: 'row', className: ROOT + '-row' }, [
|
|
249
|
+
h('button', {
|
|
250
|
+
key: 'insert',
|
|
251
|
+
type: 'button',
|
|
252
|
+
className: ROOT + '-action',
|
|
253
|
+
'data-primary': 'true',
|
|
254
|
+
disabled: text.trim() === '',
|
|
255
|
+
onClick: insert,
|
|
256
|
+
}, '插入输入框'),
|
|
257
|
+
h('button', { key: 'copy', type: 'button', className: ROOT + '-action', disabled: text === '', onClick: copy }, '复制'),
|
|
258
|
+
h('button', {
|
|
259
|
+
key: 'clip',
|
|
260
|
+
type: 'button',
|
|
261
|
+
className: ROOT + '-action',
|
|
262
|
+
onClick: () => { setPreview(''); ocr(); },
|
|
263
|
+
}, '直接读剪贴板'),
|
|
264
|
+
]),
|
|
265
|
+
])
|
|
266
|
+
: null;
|
|
267
|
+
|
|
268
|
+
return h(React.Fragment, null, h('style', { key: 'style' }, CSS), button, panel);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
inject: ['slots'],
|
|
273
|
+
apply(ctx) {
|
|
274
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
275
|
+
name: 'conversation.input.left',
|
|
276
|
+
id: 'image-ocr',
|
|
277
|
+
order: 3,
|
|
278
|
+
}, (props) => h(ImageOcr, props)));
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
});
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the image-ocr bundle.
|
|
3
|
+
*
|
|
4
|
+
* One read-only route: OCR the clipboard image with a locally-compiled Swift
|
|
5
|
+
* helper (macOS Vision framework, offline), then hand the client plain text.
|
|
6
|
+
* Compilation happens once on first use and is cached outside the package.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { homedir, tmpdir } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
export const inject = ['webServer'];
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const SOURCE = join(HERE, 'ocr.swift');
|
|
18
|
+
const CACHE_DIR = join(homedir(), 'Library', 'Application Support', 'dsh-image-ocr');
|
|
19
|
+
const BINARY = join(CACHE_DIR, 'ocr');
|
|
20
|
+
const OCR_PATH = '/plugins/image-ocr/ocr';
|
|
21
|
+
|
|
22
|
+
let enginePromise;
|
|
23
|
+
|
|
24
|
+
function sendJson(res, status, payload) {
|
|
25
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
26
|
+
res.writeHead(status);
|
|
27
|
+
res.end(JSON.stringify(payload));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJsonBody(req, limit = 16 * 1024 * 1024) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
let size = 0;
|
|
34
|
+
req.on('data', (chunk) => {
|
|
35
|
+
size += chunk.length;
|
|
36
|
+
if (size > limit) {
|
|
37
|
+
reject(new Error('request body too large'));
|
|
38
|
+
req.destroy();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
chunks.push(chunk);
|
|
42
|
+
});
|
|
43
|
+
req.on('end', () => {
|
|
44
|
+
try {
|
|
45
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
46
|
+
} catch {
|
|
47
|
+
reject(new Error('invalid JSON body'));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
req.on('error', reject);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function connectionRejection(ctx, req) {
|
|
55
|
+
const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
|
|
56
|
+
if (!connection || typeof connection.requestRejection !== 'function') return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return connection.requestRejection({ headers: req.headers });
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function compileEngine() {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
67
|
+
execFile('/usr/bin/xcrun', ['swiftc', '-O', '-o', BINARY, SOURCE], {
|
|
68
|
+
timeout: 180_000,
|
|
69
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
70
|
+
}, (error, _stdout, stderr) => {
|
|
71
|
+
if (error) {
|
|
72
|
+
reject(new Error('OCR 引擎编译失败:' + String(stderr || error.message).slice(0, 400)));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
resolve(BINARY);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ensureEngine() {
|
|
81
|
+
if (existsSync(BINARY)) return Promise.resolve(BINARY);
|
|
82
|
+
if (!enginePromise) {
|
|
83
|
+
enginePromise = compileEngine().catch((error) => {
|
|
84
|
+
enginePromise = undefined;
|
|
85
|
+
throw error;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return enginePromise;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function runOcr(binary, imagePath) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
execFile(binary, imagePath ? [imagePath] : ['--clipboard'], { timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
94
|
+
if (error) {
|
|
95
|
+
reject(new Error('OCR 执行失败:' + String(stderr || error.message).slice(0, 300)));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
resolve(String(stdout || '').replace(/\s+$/g, ''));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function apply(ctx) {
|
|
104
|
+
// 首次启动后台预热:用户第一次点按钮时通常已编译完成。
|
|
105
|
+
ensureEngine().catch((error) => {
|
|
106
|
+
ctx.logger?.warn?.('image-ocr: engine warmup failed: ' + (error instanceof Error ? error.message : String(error)));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const ocrHandler = async (req, res) => {
|
|
110
|
+
if (req.method !== 'POST') {
|
|
111
|
+
sendJson(res, 405, { ok: false, message: 'method not allowed' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const rejection = connectionRejection(ctx, req);
|
|
115
|
+
if (rejection !== undefined) {
|
|
116
|
+
sendJson(res, rejection, { ok: false, message: '未通过 DSH 连接鉴权(本地 token 无效)' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
let tempPath;
|
|
120
|
+
try {
|
|
121
|
+
const body = await readJsonBody(req);
|
|
122
|
+
let binary;
|
|
123
|
+
try {
|
|
124
|
+
binary = await ensureEngine();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
sendJson(res, 503, {
|
|
127
|
+
ok: false,
|
|
128
|
+
message: error instanceof Error ? error.message : String(error),
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const image = typeof body.image === 'string' ? body.image : '';
|
|
133
|
+
if (image !== '') {
|
|
134
|
+
const base64 = image.includes(',') ? image.slice(image.indexOf(',') + 1) : image;
|
|
135
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
136
|
+
if (buffer.length === 0) throw new Error('图片数据为空');
|
|
137
|
+
if (buffer.length > 12 * 1024 * 1024) throw new Error('图片太大(上限 12MB)');
|
|
138
|
+
tempPath = join(tmpdir(), 'dsh-image-ocr-' + Date.now() + '-' + Math.random().toString(16).slice(2, 8) + '.png');
|
|
139
|
+
writeFileSync(tempPath, buffer);
|
|
140
|
+
}
|
|
141
|
+
const text = await runOcr(binary, tempPath);
|
|
142
|
+
sendJson(res, 200, { ok: true, text, lines: text === '' ? 0 : text.split('\n').length });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
ctx.logger?.warn?.('image-ocr: ocr failed: ' + message);
|
|
146
|
+
sendJson(res, 500, { ok: false, message });
|
|
147
|
+
} finally {
|
|
148
|
+
if (tempPath) {
|
|
149
|
+
try { unlinkSync(tempPath); } catch { /* 忽略清理失败 */ }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
ctx.effect(() => ctx.webServer.register({
|
|
155
|
+
kind: 'exact',
|
|
156
|
+
path: OCR_PATH,
|
|
157
|
+
handler: ocrHandler,
|
|
158
|
+
}));
|
|
159
|
+
}
|
package/ocr.swift
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Vision
|
|
3
|
+
import AppKit
|
|
4
|
+
|
|
5
|
+
func loadImage(_ path: String?) -> CGImage? {
|
|
6
|
+
if let p = path {
|
|
7
|
+
guard let img = NSImage(contentsOfFile: p) else { return nil }
|
|
8
|
+
var rect = NSRect(origin: .zero, size: img.size)
|
|
9
|
+
return img.cgImage(forProposedRect: &rect, context: nil, hints: nil)
|
|
10
|
+
}
|
|
11
|
+
// 从剪贴板读图
|
|
12
|
+
let pb = NSPasteboard.general
|
|
13
|
+
guard let data = pb.data(forType: .png) ?? pb.data(forType: .tiff),
|
|
14
|
+
let rep = NSBitmapImageRep(data: data), let cg = rep.cgImage else { return nil }
|
|
15
|
+
return cg
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func ocr(_ cg: CGImage) -> [String] {
|
|
19
|
+
let request = VNRecognizeTextRequest()
|
|
20
|
+
request.recognitionLevel = .accurate
|
|
21
|
+
request.usesLanguageCorrection = true
|
|
22
|
+
request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"]
|
|
23
|
+
let handler = VNImageRequestHandler(cgImage: cg, options: [:])
|
|
24
|
+
do { try handler.perform([request]) } catch { return ["[OCR 失败] \(error)"] }
|
|
25
|
+
let observations = request.results ?? []
|
|
26
|
+
// 按视觉位置从上到下、从左到右排序
|
|
27
|
+
let sorted = observations.sorted { a, b in
|
|
28
|
+
let ay = a.boundingBox.midY, by = b.boundingBox.midY
|
|
29
|
+
if abs(ay - by) > 0.01 { return ay > by }
|
|
30
|
+
return a.boundingBox.minX < b.boundingBox.minX
|
|
31
|
+
}
|
|
32
|
+
return sorted.compactMap { $0.topCandidates(1).first?.string }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let args = CommandLine.arguments
|
|
36
|
+
let path = args.count > 1 && args[1] != "--clipboard" ? args[1] : nil
|
|
37
|
+
guard let cg = loadImage(path) else {
|
|
38
|
+
FileHandle.standardError.write("找不到图片(传路径或 --clipboard)\n".data(using: .utf8)!)
|
|
39
|
+
exit(2)
|
|
40
|
+
}
|
|
41
|
+
let start = Date()
|
|
42
|
+
let lines = ocr(cg)
|
|
43
|
+
print(lines.joined(separator: "\n"))
|
|
44
|
+
FileHandle.standardError.write(String(format: "[%.2fs, %d 行]\n", Date().timeIntervalSince(start), lines.count).data(using: .utf8)!)
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyra_wang/dsh-image-ocr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Composer + button: OCR the clipboard image locally (macOS Vision) and insert the text into the DSH input box.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"dsh",
|
|
9
|
+
"dsh-plugin",
|
|
10
|
+
"deepseek-harness",
|
|
11
|
+
"cordis",
|
|
12
|
+
"client-plugin",
|
|
13
|
+
"ocr",
|
|
14
|
+
"vision"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/LyraWang6688/ai-capability-store.git",
|
|
19
|
+
"directory": "external-capabilities/plugin/image-ocr"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./index.js",
|
|
23
|
+
"./client": "./client.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"index.js",
|
|
27
|
+
"client.js",
|
|
28
|
+
"cordis.patch.yml",
|
|
29
|
+
"ocr.swift",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web",
|
|
45
|
+
"immediately": true,
|
|
46
|
+
"inject": [
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|