picturereader 2.0.0 → 3.0.0
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/README.md +160 -139
- package/client.js +536 -0
- package/package.json +15 -3
- package/scripts/doc-to-image.py +194 -0
- package/scripts/setup-doc-venv.mjs +66 -0
- package/scripts/setup-rapid.mjs +85 -0
- package/src/bridge.js +162 -0
- package/src/config.js +70 -0
- package/src/core.js +94 -2
- package/src/doc-tools.js +326 -0
- package/src/image-batch.js +504 -0
- package/src/index.js +258 -32
- package/src/more-tools.js +695 -0
- package/src/picturereader-vision.mjs +164 -0
- package/src/routing.js +130 -0
- package/src/runtime.js +127 -0
- package/src/settings-expose.js +75 -0
- package/src/tool.js +19 -14
- package/src/vision-analyze.js +35 -13
- package/src/vlm.js +198 -15
package/src/core.js
CHANGED
|
@@ -1102,8 +1102,9 @@ export function runOcr(pngPath, { language } = {}) {
|
|
|
1102
1102
|
* @param buffer - raw image bytes.
|
|
1103
1103
|
* @param ext - lowercase extension ('.png' etc.).
|
|
1104
1104
|
* @param options - `{ region, language, engine }`. engine: 'windows'
|
|
1105
|
-
* (Windows.Media.Ocr, default)
|
|
1106
|
-
* paddle_venv
|
|
1105
|
+
* (Windows.Media.Ocr, default), 'paddle' (PaddleOCR via the local
|
|
1106
|
+
* paddle_venv) or 'rapid' (RapidOCR via the local rapid_venv) — Paddle and
|
|
1107
|
+
* Rapid are far better at glowing/curved/game-rendered text.
|
|
1107
1108
|
* @returns `{ width, height, lines }`.
|
|
1108
1109
|
*/
|
|
1109
1110
|
export async function ocrImage(buffer, ext, { region, language, engine = 'windows' } = {}) {
|
|
@@ -1128,6 +1129,10 @@ export async function ocrImage(buffer, ext, { region, language, engine = 'window
|
|
|
1128
1129
|
const result = await runPaddleOcr(tmpPath);
|
|
1129
1130
|
return { width: work.width, height: work.height, lines: result.lines };
|
|
1130
1131
|
}
|
|
1132
|
+
if (engine === 'rapid') {
|
|
1133
|
+
const result = await runRapidOcr(tmpPath);
|
|
1134
|
+
return { width: work.width, height: work.height, lines: result.lines };
|
|
1135
|
+
}
|
|
1131
1136
|
return await runOcr(winPath, { language });
|
|
1132
1137
|
} finally {
|
|
1133
1138
|
await rm(tmpPath, { force: true }).catch(() => {});
|
|
@@ -1225,6 +1230,93 @@ export function runPaddleOcr(pngPath) {
|
|
|
1225
1230
|
});
|
|
1226
1231
|
}
|
|
1227
1232
|
|
|
1233
|
+
/** Absolute path to the local RapidOCR environment (rapid_venv); overridable via DSH_RAPID_PYTHON. */
|
|
1234
|
+
export function rapidPython() {
|
|
1235
|
+
return process.env.DSH_RAPID_PYTHON ?? 'C:/Users/Administrator/rapid_venv/Scripts/python.exe';
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Whether the optional RapidOCR environment is available. RapidOCR is an
|
|
1240
|
+
* OPTIONAL engine: when it is missing, callers must degrade gracefully to the
|
|
1241
|
+
* Windows engine instead of failing.
|
|
1242
|
+
* @param python - python executable to probe (defaults to the configured path).
|
|
1243
|
+
* @returns true when the interpreter exists AND `rapidocr_onnxruntime` imports.
|
|
1244
|
+
*/
|
|
1245
|
+
export async function rapidAvailable(python = rapidPython()) {
|
|
1246
|
+
return new Promise((resolve) => {
|
|
1247
|
+
let settled = false;
|
|
1248
|
+
const finish = (ok) => {
|
|
1249
|
+
if (settled) return;
|
|
1250
|
+
settled = true;
|
|
1251
|
+
clearTimeout(timer);
|
|
1252
|
+
resolve(ok);
|
|
1253
|
+
};
|
|
1254
|
+
const child = spawn(python, ['-c', 'import rapidocr_onnxruntime'], {
|
|
1255
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
1256
|
+
windowsHide: true,
|
|
1257
|
+
stdio: 'ignore'
|
|
1258
|
+
});
|
|
1259
|
+
const timer = setTimeout(() => { child.kill(); finish(false); }, 30_000);
|
|
1260
|
+
child.on('error', () => finish(false));
|
|
1261
|
+
child.on('close', (code) => finish(code === 0));
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Run RapidOCR on a PNG file via the local rapid_venv. Uses the bundled
|
|
1267
|
+
* det/rec/cls ONNX models (no network download on first run — verified on
|
|
1268
|
+
* rapidocr_onnxruntime 1.2.3). Better than Windows OCR for glowing, curved,
|
|
1269
|
+
* or game-rendered text.
|
|
1270
|
+
* @param pngPath - absolute path to the PNG (forward slashes recommended).
|
|
1271
|
+
* @returns `{ lines: [{ text, score, x, y, width, height }] }` (box aggregated).
|
|
1272
|
+
*/
|
|
1273
|
+
export function runRapidOcr(pngPath) {
|
|
1274
|
+
const script = [
|
|
1275
|
+
'import json, sys',
|
|
1276
|
+
'from rapidocr_onnxruntime import RapidOCR',
|
|
1277
|
+
'_engine = RapidOCR()',
|
|
1278
|
+
'_result, _elapse = _engine(sys.argv[1])',
|
|
1279
|
+
'_out = []',
|
|
1280
|
+
'for _it in (_result or []):',
|
|
1281
|
+
' _pts = [[float(c) for c in _p] for _p in _it[0]]',
|
|
1282
|
+
' _xs = [_p[0] for _p in _pts]; _ys = [_p[1] for _p in _pts]',
|
|
1283
|
+
" _out.append({'text': _it[1], 'score': float(_it[2]), 'x': int(min(_xs)), 'y': int(min(_ys)), 'width': int(max(_xs)-min(_xs)), 'height': int(max(_ys)-min(_ys))})",
|
|
1284
|
+
"print(json.dumps({'lines': _out}, ensure_ascii=False), flush=True)"
|
|
1285
|
+
].join('\n');
|
|
1286
|
+
return new Promise((resolve, reject) => {
|
|
1287
|
+
const child = spawn(rapidPython(), ['-c', script, String(pngPath)], {
|
|
1288
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
1289
|
+
windowsHide: true
|
|
1290
|
+
});
|
|
1291
|
+
let stdout = '';
|
|
1292
|
+
let stderr = '';
|
|
1293
|
+
const timer = setTimeout(() => {
|
|
1294
|
+
child.kill();
|
|
1295
|
+
reject(new Error('image_ocr: RapidOCR timed out after 60s'));
|
|
1296
|
+
}, 60_000);
|
|
1297
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
1298
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1299
|
+
child.on('error', (error) => {
|
|
1300
|
+
clearTimeout(timer);
|
|
1301
|
+
reject(new Error(`image_ocr: cannot start RapidOCR: ${error.message}`));
|
|
1302
|
+
});
|
|
1303
|
+
child.on('close', (code) => {
|
|
1304
|
+
clearTimeout(timer);
|
|
1305
|
+
if (code !== 0) {
|
|
1306
|
+
const tail = stderr.trim().split('\n').filter((l) => l.includes('Error') || l.includes('error') || l.includes('Traceback')).slice(-3).join(' | ') || stderr.trim().slice(-200);
|
|
1307
|
+
reject(new Error(`image_ocr: RapidOCR failed (exit ${code}): ${tail}`));
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
try {
|
|
1311
|
+
const parsed = JSON.parse(stdout.trim());
|
|
1312
|
+
resolve({ lines: parsed.lines ?? [] });
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
reject(new Error(`image_ocr: cannot parse RapidOCR result: ${error.message}`));
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1228
1320
|
/**
|
|
1229
1321
|
* Render OCR results as model-facing text.
|
|
1230
1322
|
* @param value - `{ path, width, height, region, lines }`.
|
package/src/doc-tools.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* picturereader — document_to_image tool.
|
|
3
|
+
*
|
|
4
|
+
* Converts a local Office/PDF document into a list of per-page PNG paths so a
|
|
5
|
+
* text-only model can then analyze them with the existing image_scan /
|
|
6
|
+
* image_ocr / image_sample / vision_analyze tools. Purely local (no network).
|
|
7
|
+
*
|
|
8
|
+
* Supported inputs: .pdf / .docx / .doc / .xlsx / .xls / .pptx / .ppt
|
|
9
|
+
*
|
|
10
|
+
* Conversion chain (runs in the isolated doc_venv Python via scripts/
|
|
11
|
+
* doc-to-image.py so the timeouts / page caps / LibreOffice handling stay in
|
|
12
|
+
* one reusable place):
|
|
13
|
+
*
|
|
14
|
+
* .pdf ──────────────► PyMuPDF(fitz) render each page to PNG
|
|
15
|
+
* office ──LibreOffice──► PDF ──fitz──► PNG
|
|
16
|
+
* (soffice --headless --convert-to pdf, independent profile)
|
|
17
|
+
*
|
|
18
|
+
* Environment requirements (checked at runtime, with clear messages instead
|
|
19
|
+
* of crashes):
|
|
20
|
+
* - doc_venv at `C:\Users\Administrator\doc_venv\Scripts\python.exe` with
|
|
21
|
+
* pymupdf installed → else hint "run node scripts/setup-doc-venv.mjs".
|
|
22
|
+
* - LibreOffice soffice.exe → read from `DSH_SOFFICE` env, default
|
|
23
|
+
* `C:/Program Files/LibreOffice/program/soffice.exe` (glob-fallback for
|
|
24
|
+
* case). Missing → hint to install LibreOffice / set DSH_SOFFICE.
|
|
25
|
+
*
|
|
26
|
+
* @module picturereader/doc-tools
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { extname, join, basename as pathBasename, resolve as pathResolve, dirname } from 'node:path';
|
|
30
|
+
import { tmpdir } from 'node:os';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
33
|
+
import { randomBytes } from 'node:crypto';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
/** Absolute path to scripts/doc-to-image.py (this module lives in src/). */
|
|
37
|
+
const SCRIPT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'doc-to-image.py');
|
|
38
|
+
|
|
39
|
+
/** The isolated venv python used to run the conversion chain (env overridable). */
|
|
40
|
+
const DOC_VENV_PY = process.env.DSH_DOC_PYTHON ?? 'C:\\Users\\Administrator\\doc_venv\\Scripts\\python.exe';
|
|
41
|
+
|
|
42
|
+
/** Hard cap on how many bytes we read into memory per input document. */
|
|
43
|
+
const MAX_INPUT_BYTES = 512 * 1024 * 1024; // 512 MB
|
|
44
|
+
|
|
45
|
+
const SUPPORTED_EXTS = new Set(['.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt']);
|
|
46
|
+
|
|
47
|
+
/** Validate an integer in [min, max], throwing a tool-prefixed error. */
|
|
48
|
+
function parseBoundedInt(raw, fallback, min, max, label) {
|
|
49
|
+
const n = raw === undefined ? fallback : Number(raw);
|
|
50
|
+
if (!Number.isInteger(n) || n < min || n > max) {
|
|
51
|
+
throw new Error(`document_to_image: ${label} must be an integer between ${min} and ${max}`);
|
|
52
|
+
}
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function throwIfAborted(signal) {
|
|
57
|
+
if (signal?.aborted) throw new Error('document_to_image: cancelled');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolve a writable out_dir: explicit path, or a temp dir under the OS tmp. */
|
|
61
|
+
function resolveOutDir(raw, fingerprint, cwd) {
|
|
62
|
+
if (raw !== undefined && raw !== null && String(raw).trim().length > 0) {
|
|
63
|
+
const p = String(raw).trim();
|
|
64
|
+
// Resolve relative paths against the session cwd like other tools.
|
|
65
|
+
return cwd ? pathResolve(cwd, p) : pathResolve(p);
|
|
66
|
+
}
|
|
67
|
+
const stamp = fingerprint && fingerprint !== 'anon' ? fingerprint : 'anon';
|
|
68
|
+
return join(tmpdir(), 'picturereader-doc', stamp, `${Date.now()}-${randomBytes(4).toString('hex')}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run the doc-to-image.py conversion for a single document that has already
|
|
73
|
+
* been materialized at a real local path. Returns the parsed JSON summary.
|
|
74
|
+
*/
|
|
75
|
+
function runDocPython(inputPath, outDir, prefix, dpi, maxPages, timeoutMs, signal) {
|
|
76
|
+
throwIfAborted(signal);
|
|
77
|
+
const args = [SCRIPT_PATH, inputPath, outDir, prefix, String(dpi), String(maxPages)];
|
|
78
|
+
const res = spawnSync(DOC_VENV_PY, args, {
|
|
79
|
+
encoding: 'utf8',
|
|
80
|
+
timeout: timeoutMs,
|
|
81
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
82
|
+
...(signal ? { signal } : {}),
|
|
83
|
+
});
|
|
84
|
+
if (res.error) {
|
|
85
|
+
if (res.error.code === 'ABORT_ERR' || signal?.aborted) {
|
|
86
|
+
throw new Error('document_to_image: cancelled');
|
|
87
|
+
}
|
|
88
|
+
if (res.error.code === 'ENOENT') {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`document_to_image: 转换所需的 Python 环境缺失。请先运行 \`node scripts/setup-doc-venv.mjs\` 创建 doc_venv(位于 ${DOC_VENV_PY})。`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (res.error.code === 'ETIMEDOUT') {
|
|
94
|
+
throw new Error('document_to_image: 转换超时(>120s),请检查文档是否损坏、过大,或降低 max_pages / dpi。');
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`document_to_image: 调用转换脚本失败: ${res.error.message}`);
|
|
97
|
+
}
|
|
98
|
+
if (res.signal && res.signal === 'SIGTERM' && signal?.aborted) {
|
|
99
|
+
throw new Error('document_to_image: cancelled');
|
|
100
|
+
}
|
|
101
|
+
if (res.signal || res.status === null) {
|
|
102
|
+
throw new Error('document_to_image: 转换进程被终止(超时或中断)');
|
|
103
|
+
}
|
|
104
|
+
if (res.status !== 0 || !res.stdout) {
|
|
105
|
+
// 失败或空输出:脚本以非零状态退出,stderr/stdout 里有 JSON error。
|
|
106
|
+
const body = (res.stderr || res.stdout || '').trim();
|
|
107
|
+
let msg = body;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(body.split('\n')[0]);
|
|
110
|
+
if (parsed && parsed.error) msg = parsed.error;
|
|
111
|
+
} catch { /* body is raw text */ }
|
|
112
|
+
throw new Error(`document_to_image: 转换失败: ${msg || `退出码 ${res.status}`}`);
|
|
113
|
+
}
|
|
114
|
+
// 成功路径:解析最后一行 JSON(脚本只打印一行 JSON)。
|
|
115
|
+
const line = res.stdout.trim().split('\n').filter(Boolean).pop();
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(line);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
throw new Error(`document_to_image: 无法解析转换脚本输出: ${e.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build the `document_to_image` tool.
|
|
125
|
+
* @param ctx - the Cordis context providing `ctx.fs`.
|
|
126
|
+
*/
|
|
127
|
+
export function createDocumentToImageTool(ctx) {
|
|
128
|
+
return {
|
|
129
|
+
name: 'document_to_image',
|
|
130
|
+
description: [
|
|
131
|
+
'Convert a local Office/PDF document (pdf / docx / doc / xlsx / xls / pptx / ppt) into a list of per-page PNG image paths, ' +
|
|
132
|
+
'so the pages can then be inspected with the existing image_scan / image_ocr / image_sample / vision_analyze tools. Purely local (no network).',
|
|
133
|
+
'Parameters: file_path (required, a single document) — or file_paths (array) to convert several documents in one call; ' +
|
|
134
|
+
'out_dir (optional, output directory; defaults to a temp dir under the system temp); ' +
|
|
135
|
+
'dpi (optional 72..300, default 150 — higher = sharper but larger PNGs); ' +
|
|
136
|
+
'max_pages (optional 1..500, default 50 — render only the first N pages of multi-page docs).',
|
|
137
|
+
'Returns, per document: input (original name), page_count (total page count), ' +
|
|
138
|
+
'pages: [{ index, path, width, height, bytes }], out_dir (where the PNGs live), and a summary.',
|
|
139
|
+
'The PNGs remain on disk in out_dir so subsequent image_scan / image_ocr calls can read them by path.',
|
|
140
|
+
'PDFs render directly with PyMuPDF; other Office formats are first converted to PDF via headless LibreOffice. ' +
|
|
141
|
+
'Requires the doc_venv Python (pymupdf) and LibreOffice — if either is missing the tool returns a clear setup hint.'
|
|
142
|
+
].join(' '),
|
|
143
|
+
parameters: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
additionalProperties: true,
|
|
146
|
+
properties: {
|
|
147
|
+
file_path: {
|
|
148
|
+
type: 'string',
|
|
149
|
+
description: 'Path to a single document (pdf/docx/doc/xlsx/xls/pptx/ppt). Use either this or file_paths, not both.'
|
|
150
|
+
},
|
|
151
|
+
file_paths: {
|
|
152
|
+
type: 'array',
|
|
153
|
+
items: { type: 'string' },
|
|
154
|
+
description: 'Array of document paths to convert in one call (batch). Use either this or file_path, not both.'
|
|
155
|
+
},
|
|
156
|
+
out_dir: {
|
|
157
|
+
type: 'string',
|
|
158
|
+
description: 'Optional output directory for the generated PNGs. Defaults to a temp dir under the system temp.'
|
|
159
|
+
},
|
|
160
|
+
dpi: {
|
|
161
|
+
type: 'integer',
|
|
162
|
+
description: 'Render resolution in dots per inch (72..300, default 150).'
|
|
163
|
+
},
|
|
164
|
+
max_pages: {
|
|
165
|
+
type: 'integer',
|
|
166
|
+
description: 'Maximum number of pages to render (1..500, default 50). Pages beyond this are skipped (noted).'
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
output: {
|
|
171
|
+
schema: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
additionalProperties: true,
|
|
174
|
+
properties: {
|
|
175
|
+
documents: {
|
|
176
|
+
type: 'array',
|
|
177
|
+
items: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
additionalProperties: true,
|
|
180
|
+
properties: {
|
|
181
|
+
input: { type: 'string' },
|
|
182
|
+
page_count: { type: 'integer' },
|
|
183
|
+
rendered: { type: 'integer' },
|
|
184
|
+
truncated: { type: 'boolean' },
|
|
185
|
+
pages: {
|
|
186
|
+
type: 'array',
|
|
187
|
+
items: {
|
|
188
|
+
type: 'object',
|
|
189
|
+
additionalProperties: true,
|
|
190
|
+
properties: {
|
|
191
|
+
index: { type: 'integer' },
|
|
192
|
+
path: { type: 'string' },
|
|
193
|
+
width: { type: 'integer' },
|
|
194
|
+
height: { type: 'integer' },
|
|
195
|
+
bytes: { type: 'integer' }
|
|
196
|
+
},
|
|
197
|
+
required: ['index', 'path', 'width', 'height', 'bytes']
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
required: ['input', 'page_count', 'rendered', 'pages']
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
out_dir: { type: 'string' },
|
|
205
|
+
summary: { type: 'string' },
|
|
206
|
+
note: { type: 'string' }
|
|
207
|
+
},
|
|
208
|
+
required: ['documents', 'out_dir', 'summary']
|
|
209
|
+
},
|
|
210
|
+
render: (_args, value) => {
|
|
211
|
+
const lines = [`documents converted to images (out_dir: ${value.out_dir})`];
|
|
212
|
+
for (const d of value.documents || []) {
|
|
213
|
+
lines.push(` ${d.input}: ${d.rendered}/${d.page_count} page(s) rendered${d.truncated ? ' (truncated)' : ''}`);
|
|
214
|
+
for (const p of d.pages || []) {
|
|
215
|
+
lines.push(` page ${p.index}: ${p.width}x${p.height}px, ${p.bytes} bytes → ${p.path}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
lines.push(value.summary || '');
|
|
219
|
+
if (value.note) lines.push(value.note);
|
|
220
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
isConcurrencySafe: () => true,
|
|
224
|
+
async execute(args, exec) {
|
|
225
|
+
throwIfAborted(exec.signal);
|
|
226
|
+
// ---- 参数收集与校验 ----
|
|
227
|
+
const dpi = parseBoundedInt(args.dpi, 150, 72, 300, 'dpi');
|
|
228
|
+
const maxPages = parseBoundedInt(args.max_pages, 50, 1, 500, 'max_pages');
|
|
229
|
+
|
|
230
|
+
const fp = typeof args.file_path === 'string' ? args.file_path.trim() : '';
|
|
231
|
+
const fps = Array.isArray(args.file_paths)
|
|
232
|
+
? args.file_paths.filter((x) => typeof x === 'string' && x.trim().length > 0).map((x) => x.trim())
|
|
233
|
+
: [];
|
|
234
|
+
if (fp.length > 0 && fps.length > 0) {
|
|
235
|
+
throw new Error('document_to_image: 请只传 file_path(单个)或 file_paths(批量),不要同时传两者。');
|
|
236
|
+
}
|
|
237
|
+
const targets = fp.length > 0 ? [fp] : fps;
|
|
238
|
+
if (targets.length === 0) {
|
|
239
|
+
throw new Error('document_to_image: 需要一个输入文件(file_path 或 file_paths)。');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const cwd = exec.agent?.session?.header?.cwd;
|
|
243
|
+
const fingerprint = (exec.agent?.session?.id) || 'anon';
|
|
244
|
+
const outDir = resolveOutDir(args.out_dir, fingerprint, cwd);
|
|
245
|
+
|
|
246
|
+
// 预解析目标:解析路径、校验扩展名、读字节并落盘到临时目录(python 需真实本地路径)。
|
|
247
|
+
const materialized = []; // { ext, localPath, displayPath }
|
|
248
|
+
for (const rawPath of targets) {
|
|
249
|
+
throwIfAborted(exec.signal);
|
|
250
|
+
const target = await ctx.fs.resolve(rawPath, {
|
|
251
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
252
|
+
signal: exec.signal
|
|
253
|
+
});
|
|
254
|
+
const display = target.displayPath;
|
|
255
|
+
const ext = extname(display).toLowerCase();
|
|
256
|
+
if (!SUPPORTED_EXTS.has(ext)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`document_to_image: 不支持的文件类型 "${ext}"(支持: pdf / docx / doc / xlsx / xls / pptx / ppt): ${display}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
262
|
+
if (!info) throw new Error(`document_to_image: 找不到文件: ${display}`);
|
|
263
|
+
if (info.type !== 'file') throw new Error(`document_to_image: 不是普通文件: ${display}`);
|
|
264
|
+
const bytes = await ctx.fs.readBytes(target, exec.signal, MAX_INPUT_BYTES);
|
|
265
|
+
// 落盘:临时目录 + 保留原扩展名(python 靠扩展名判断链路)。
|
|
266
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'picturereader-src-'));
|
|
267
|
+
const localPath = join(tmpDir, `${pathBasename(display) || 'doc'}${Date.now()}-${randomBytes(2).toString('hex')}${ext}`);
|
|
268
|
+
writeFileSync(localPath, bytes);
|
|
269
|
+
materialized.push({ ext, localPath, displayPath: display, tmpDir });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const documents = [];
|
|
273
|
+
// 可注入 seam:测试可传 ctx._docRunner 替换真实 spawn(与 image-batch 的 ctx.ocrImage 注入一致)。
|
|
274
|
+
const runner = (typeof ctx._docRunner === 'function') ? ctx._docRunner : runDocPython;
|
|
275
|
+
try {
|
|
276
|
+
for (let i = 0; i < materialized.length; i += 1) {
|
|
277
|
+
throwIfAborted(exec.signal);
|
|
278
|
+
const { ext, localPath, displayPath } = materialized[i];
|
|
279
|
+
const prefix = `page_${i + 1}`; // 每文档一个独立前缀,批量时同 base 名互不覆盖
|
|
280
|
+
const summary = runner(localPath, outDir, prefix, dpi, maxPages, 120_000, exec.signal);
|
|
281
|
+
if (summary.error) {
|
|
282
|
+
throw new Error(`document_to_image: ${summary.error}`);
|
|
283
|
+
}
|
|
284
|
+
documents.push({
|
|
285
|
+
input: pathBasename(displayPath),
|
|
286
|
+
page_count: summary.page_count ?? 0,
|
|
287
|
+
rendered: (summary.pages || []).length,
|
|
288
|
+
truncated: !!summary.truncated,
|
|
289
|
+
pages: (summary.pages || []).map((p) => ({
|
|
290
|
+
index: p.index,
|
|
291
|
+
path: p.path,
|
|
292
|
+
width: p.width,
|
|
293
|
+
height: p.height,
|
|
294
|
+
bytes: p.bytes
|
|
295
|
+
}))
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
// 清理源文件的临时落盘(PNG 输出保留在 out_dir 供后续工具读)。
|
|
300
|
+
for (const m of materialized) {
|
|
301
|
+
try {
|
|
302
|
+
rmSync(m.tmpDir, { recursive: true, force: true });
|
|
303
|
+
} catch { /* best effort */ }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const totalPages = documents.reduce((s, d) => s + d.rendered, 0);
|
|
308
|
+
const truncatedAny = documents.some((d) => d.truncated);
|
|
309
|
+
const summary =
|
|
310
|
+
`转换完成:${documents.length} 个文档,共渲染 ${totalPages} 页 PNG,输出目录 ${outDir}。` +
|
|
311
|
+
(truncatedAny ? ' 部分文档超过 max_pages 仅渲染前 N 页,如需更多页请分批(提高 max_pages 或缩小 dpi)。' : '');
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
documents,
|
|
315
|
+
out_dir: outDir,
|
|
316
|
+
summary,
|
|
317
|
+
note: '每页 PNG 可直接用 image_scan / image_ocr / image_sample / vision_analyze 按 pages[].path 分析。'
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 注册工厂,与 more-tools.js 的 registerMoreTools 风格一致(主会话按需调用)。
|
|
324
|
+
export function registerDocTools(ctx) {
|
|
325
|
+
ctx.tools.register(createDocumentToImageTool(ctx));
|
|
326
|
+
}
|