@deepwhale-cn/dsh-doc-batch 0.0.0-stage → 0.1.3

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.
@@ -0,0 +1,581 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PDF 工具箱 · CLI(Node ESM)
4
+ *
5
+ * 设计:Node 只做参数解析 / 依赖探测 / 输出命名与展示;
6
+ * 真正的 PDF 读写交给 Python + PyMuPDF(lib/pdf_ops.py),
7
+ * 通过 spawnSync 调用,stdin 传 JSON 任务,stdout 收 JSON 结果。
8
+ *
9
+ * 契约:
10
+ * - 不改输入文件;只写 --out(默认 <输入目录>/_输出)
11
+ * - 同名不覆盖,自动加 -1 / -2 后缀
12
+ * - 依赖缺失 / 参数错误明确报「缺什么、怎么装」,非 0 退出
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import {
16
+ existsSync, mkdirSync, mkdtempSync, readdirSync, statSync,
17
+ } from 'node:fs';
18
+ import { basename, dirname, extname, join, resolve } from 'node:path';
19
+ import { tmpdir } from 'node:os';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
23
+ const OPS = join(PKG_ROOT, 'lib', 'pdf_ops.py');
24
+ const CMD_NAME = 'dsh-pdf-kit';
25
+
26
+ const COMMANDS = {
27
+ merge: { actions: [], modes: {} },
28
+ split: {
29
+ actions: ['page', 'by-page', 'every', 'every-n'],
30
+ modes: { page: 'page', 'by-page': 'page', every: 'every', 'every-n': 'every' },
31
+ },
32
+ compress: { actions: [], modes: {} },
33
+ watermark: { actions: ['text'], modes: { text: 'text' } },
34
+ 'extract-text': { actions: [], modes: {} },
35
+ 'extract-images': { actions: [], modes: {} },
36
+ };
37
+
38
+ const VALUE_FLAGS = new Set([
39
+ '--input', '--out', '--n', '--quality', '--dpi', '--text',
40
+ '--fontsize', '--opacity', '--min-bytes',
41
+ ]);
42
+ const BOOL_FLAGS = new Set(['--json', '--demo', '--probe', '--help', '-h']);
43
+
44
+ // ------------------------------------------------------------------ 输出/退出
45
+
46
+ function emit(json, payload) {
47
+ if (json) console.log(JSON.stringify(payload));
48
+ }
49
+
50
+ function fail(message, { hint = '', code = 2, json = false, extra = {} } = {}) {
51
+ if (json) {
52
+ console.log(JSON.stringify({ ok: false, error: message, hint, ...extra }));
53
+ } else {
54
+ console.error(`❌ ${message}`);
55
+ if (hint) {
56
+ for (const line of String(hint).split('\n')) console.error(` ${line}`);
57
+ }
58
+ }
59
+ process.exit(code);
60
+ }
61
+
62
+ function fmtBytes(n) {
63
+ if (n == null) return '—';
64
+ const units = ['B', 'KB', 'MB', 'GB'];
65
+ let v = n;
66
+ let i = 0;
67
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
68
+ return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
69
+ }
70
+
71
+ // ------------------------------------------------------------------ 帮助
72
+
73
+ function printHelp() {
74
+ console.log(`PDF 工具箱 · ${CMD_NAME}
75
+
76
+ 一句话:PDF 合并 / 按页拆分 / 压缩 / 整页文字水印 / 提取文本 / 导出图片
77
+
78
+ 用法:
79
+ ${CMD_NAME} <子命令> [动作] --input <文件或目录> [--out <目录>] [--json]
80
+ ${CMD_NAME} --demo
81
+ ${CMD_NAME} --help
82
+
83
+ 子命令(动作可省略;也支持 ${CMD_NAME} pdf merge 这种带前缀写法):
84
+ merge 合并多个 PDF(--input 可重复,或逗号分隔)
85
+ split 拆分:默认按页;动作 every-n / 选项 --n N 表示每 N 页一份
86
+ compress 重存 + 图片降采样重编码(--quality 1-95,--dpi 36-600)
87
+ watermark 整页平铺文字水印(--text 必填,--fontsize,--opacity 0-1)
88
+ extract-text 每页文本各导出一个 .txt(UTF-8)
89
+ extract-images 导出页面内嵌图片(保留原始字节)
90
+
91
+ 示例:
92
+ ${CMD_NAME} merge --input a.pdf,b.pdf --out out
93
+ ${CMD_NAME} split every-n --n 2 --input report.pdf
94
+ ${CMD_NAME} watermark --text "内部资料" --input a.pdf
95
+ ${CMD_NAME} compress --quality 60 --input big.pdf
96
+ ${CMD_NAME} --demo # 自造样例跑完整流程,打印产物路径
97
+
98
+ 通用选项:
99
+ --input <路径> 输入 PDF 或目录(可重复)
100
+ --out <目录> 输出目录(默认 <输入目录>/_输出)
101
+ --json 机器可读输出
102
+ --probe 探测 Python / PyMuPDF 版本(诊断用)
103
+ -h, --help 显示帮助
104
+
105
+ 约定:
106
+ · 不修改输入文件;只写输出目录;同名文件自动加 -1 / -2,不覆盖。
107
+ · 依赖 python3 + PyMuPDF;缺失时明确提示「缺什么、怎么装」。
108
+ · 退出码:0 成功 / 2 参数或输入错误 / 3 依赖缺失 / 4 执行失败。`);
109
+ }
110
+
111
+ // ------------------------------------------------------------------ 参数解析
112
+
113
+ function parseArgs(argv) {
114
+ const opts = {
115
+ inputs: [], out: null, json: false, demo: false, probe: false, help: false,
116
+ n: null, quality: null, dpi: null, text: null, fontsize: null, opacity: null,
117
+ minBytes: null, positionals: [],
118
+ };
119
+ for (let i = 0; i < argv.length; i += 1) {
120
+ const raw = argv[i];
121
+ const eq = raw.startsWith('--') ? raw.indexOf('=') : -1;
122
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
123
+ const inline = eq > 0 ? raw.slice(eq + 1) : null;
124
+ if (BOOL_FLAGS.has(flag)) {
125
+ if (flag === '--json') opts.json = true;
126
+ else if (flag === '--demo') opts.demo = true;
127
+ else if (flag === '--probe') opts.probe = true;
128
+ else opts.help = true;
129
+ continue;
130
+ }
131
+ if (VALUE_FLAGS.has(flag)) {
132
+ let value = inline;
133
+ if (value == null) {
134
+ value = argv[i + 1];
135
+ if (value == null || (value.startsWith('--') && value !== '-')) {
136
+ fail(`参数 ${flag} 缺少值`, { hint: `例如:${flag} <值>`, json: opts.json });
137
+ }
138
+ i += 1;
139
+ }
140
+ switch (flag) {
141
+ case '--input':
142
+ for (const part of value.split(',')) {
143
+ const t = part.trim();
144
+ if (t) opts.inputs.push(t);
145
+ }
146
+ break;
147
+ case '--out': opts.out = value; break;
148
+ case '--n': opts.n = value; break;
149
+ case '--quality': opts.quality = value; break;
150
+ case '--dpi': opts.dpi = value; break;
151
+ case '--text': opts.text = value; break;
152
+ case '--fontsize': opts.fontsize = value; break;
153
+ case '--opacity': opts.opacity = value; break;
154
+ case '--min-bytes': opts.minBytes = value; break;
155
+ default: break;
156
+ }
157
+ continue;
158
+ }
159
+ if (raw.startsWith('-') && raw !== '-') {
160
+ fail(`未知参数:${raw}`, { hint: '用 --help 查看支持的参数', json: opts.json });
161
+ }
162
+ opts.positionals.push(raw);
163
+ }
164
+ return opts;
165
+ }
166
+
167
+ // ------------------------------------------------------------------ 依赖探测
168
+
169
+ function findPython() {
170
+ const candidates = [
171
+ process.env.DSH_PYTHON,
172
+ '/opt/homebrew/bin/python3',
173
+ '/usr/local/bin/python3',
174
+ 'python3',
175
+ ].filter(Boolean);
176
+ const tried = [];
177
+ for (const cand of candidates) {
178
+ const r = spawnSync(cand, ['-c', 'import sys; print(sys.executable)'], { encoding: 'utf8' });
179
+ tried.push(cand);
180
+ if (!r.error && r.status === 0) {
181
+ const exe = (r.stdout || '').trim().split('\n').filter(Boolean).pop();
182
+ if (exe) return { python: exe, tried };
183
+ }
184
+ }
185
+ return { python: null, tried };
186
+ }
187
+
188
+ function ensurePython(json) {
189
+ const { python, tried } = findPython();
190
+ if (!python) {
191
+ fail('缺少 python3:找不到可用的 Python 解释器', {
192
+ code: 3,
193
+ json,
194
+ hint: `已尝试:${tried.join('、')}
195
+ 安装:brew install python3
196
+ 或设置环境变量 DSH_PYTHON 指向 python3 绝对路径`,
197
+ extra: { dependency: 'python3' },
198
+ });
199
+ }
200
+ return python;
201
+ }
202
+
203
+ function ensureFitz(python, json) {
204
+ const code = 'import fitz, json; print(json.dumps({"fitz": getattr(fitz, "VersionBind", "unknown")}))';
205
+ const r = spawnSync(python, ['-c', code], { encoding: 'utf8' });
206
+ if (r.error || r.status !== 0) {
207
+ const detail = (r.stderr || r.stdout || String(r.error || '')).trim().split('\n').slice(-3).join('\n');
208
+ fail('缺少 PyMuPDF(python 包 fitz):无法读写 PDF', {
209
+ code: 3,
210
+ json,
211
+ hint: `安装:${python} -m pip install PyMuPDF
212
+ 如系统 Python 受限,可用:${python} -m pip install --user PyMuPDF
213
+ ${detail ? `底层报错:${detail}` : ''}`,
214
+ extra: { dependency: 'PyMuPDF' },
215
+ });
216
+ }
217
+ const line = (r.stdout || '').trim().split('\n').filter(Boolean).pop();
218
+ try {
219
+ return JSON.parse(line);
220
+ } catch {
221
+ return { fitz: 'unknown' };
222
+ }
223
+ }
224
+
225
+ function runOp(python, job) {
226
+ const r = spawnSync(python, [OPS], {
227
+ input: JSON.stringify(job),
228
+ encoding: 'utf8',
229
+ maxBuffer: 64 * 1024 * 1024,
230
+ });
231
+ const stdout = (r.stdout || '').trim();
232
+ const stderr = (r.stderr || '').trim();
233
+ let parsed = null;
234
+ if (stdout) {
235
+ const lines = stdout.split('\n').filter(Boolean);
236
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
237
+ try { parsed = JSON.parse(lines[i]); break; } catch { /* 继续找 */ }
238
+ }
239
+ }
240
+ if (parsed) return parsed;
241
+ return {
242
+ ok: false,
243
+ error: 'python-op-failed',
244
+ detail: `Python 侧没有返回结果(退出码 ${r.status})`,
245
+ stderr: stderr.split('\n').slice(-6).join('\n'),
246
+ };
247
+ }
248
+
249
+ function unwrap(result, step, json) {
250
+ if (!result || result.ok !== true) {
251
+ const detail = result?.detail || result?.error || '未知错误';
252
+ const stderr = result?.stderr ? `\n${result.stderr}` : '';
253
+ fail(`${step} 执行失败:${detail}${stderr}`, {
254
+ code: 4,
255
+ json,
256
+ hint: result?.hint || '可加 --json 查看结构化错误;输入文件损坏或加密时也会失败',
257
+ extra: { step, detail: String(detail) },
258
+ });
259
+ }
260
+ return result;
261
+ }
262
+
263
+ // ------------------------------------------------------------------ 输入/输出
264
+
265
+ const PDF_EXT = '.pdf';
266
+
267
+ function collectInputs(opts, json) {
268
+ const seen = new Set();
269
+ const files = [];
270
+ for (const raw of opts.inputs) {
271
+ const abs = resolve(raw);
272
+ if (!existsSync(abs)) {
273
+ fail(`输入不存在:${raw}(解析为 ${abs})`, {
274
+ code: 2,
275
+ json,
276
+ hint: '确认路径拼写;支持 PDF 文件或包含 PDF 的目录',
277
+ extra: { input: raw },
278
+ });
279
+ }
280
+ const st = statSync(abs);
281
+ if (st.isDirectory()) {
282
+ const entries = readdirSync(abs)
283
+ .filter((f) => extname(f).toLowerCase() === PDF_EXT)
284
+ .filter((f) => {
285
+ const full = join(abs, f);
286
+ try { return statSync(full).isFile(); } catch { return false; }
287
+ })
288
+ .sort((a, b) => a.localeCompare(b, 'zh-CN'));
289
+ if (entries.length === 0) {
290
+ fail(`目录里没有 PDF 文件:${raw}`, {
291
+ code: 2, json, hint: '把 PDF 放进该目录,或直接传文件路径', extra: { input: raw },
292
+ });
293
+ }
294
+ for (const f of entries) {
295
+ const full = resolve(join(abs, f));
296
+ if (!seen.has(full)) { seen.add(full); files.push(full); }
297
+ }
298
+ } else if (st.isFile()) {
299
+ if (extname(abs).toLowerCase() !== PDF_EXT) {
300
+ fail(`不是 PDF 文件:${raw}`, {
301
+ code: 2, json, hint: '本工具只处理 .pdf;其它格式请先转换', extra: { input: raw },
302
+ });
303
+ }
304
+ if (!seen.has(abs)) { seen.add(abs); files.push(abs); }
305
+ } else {
306
+ fail(`无法识别的输入(既不是文件也不是目录):${raw}`, { code: 2, json });
307
+ }
308
+ }
309
+ return files;
310
+ }
311
+
312
+ function defaultOutDir(files, opts) {
313
+ if (opts.out) return resolve(opts.out);
314
+ const firstDir = dirname(files[0]);
315
+ return join(firstDir, '_输出');
316
+ }
317
+
318
+ function ensureDir(dir, json) {
319
+ try {
320
+ mkdirSync(dir, { recursive: true });
321
+ } catch (e) {
322
+ fail(`无法创建输出目录:${dir}`, {
323
+ code: 2, json, hint: String(e?.message || e), extra: { out: dir },
324
+ });
325
+ }
326
+ }
327
+
328
+ function stemOf(file) {
329
+ return basename(file, extname(file));
330
+ }
331
+
332
+ // ------------------------------------------------------------------ 人类输出
333
+
334
+ function printResult(step, result) {
335
+ const s = result.stats || {};
336
+ const outputs = result.outputs || [];
337
+ if (step === 'merge') {
338
+ console.log(`✅ merge 完成 · ${s.files ?? '?'} 个 PDF → ${s.pages} 页`);
339
+ } else if (step === 'split') {
340
+ console.log(`✅ split 完成 · 共 ${s.pages} 页 → ${s.parts} 个文件(${s.mode === 'every' ? `每 ${s.n} 页` : '按页'})`);
341
+ } else if (step === 'compress') {
342
+ const pct = s.beforeBytes ? ((1 - s.afterBytes / s.beforeBytes) * 100) : 0;
343
+ const mark = s.smaller ? `-${pct.toFixed(1)}%` : `未变小(${pct.toFixed(1)}%)`;
344
+ console.log(`✅ compress 完成 · ${fmtBytes(s.beforeBytes)} → ${fmtBytes(s.afterBytes)}(${mark})· 图片 ${s.images ?? 0} 张`);
345
+ } else if (step === 'watermark') {
346
+ console.log(`✅ watermark 完成 · ${s.pages} 页 · 文字「${s.text}」平铺 ${s.placements} 处 · 不透明度 ${s.opacity}`);
347
+ } else if (step === 'extract-text') {
348
+ console.log(`✅ extract-text 完成 · ${s.pages} 页 → ${s.chars} 字符`);
349
+ } else if (step === 'extract-images') {
350
+ console.log(`✅ extract-images 完成 · ${s.pages} 页 → ${s.images} 张图片${s.skipped ? `(跳过 ${s.skipped} 张)` : ''}`);
351
+ } else {
352
+ console.log(`✅ ${step} 完成`);
353
+ }
354
+ for (const out of outputs) console.log(` 产物:${out}`);
355
+ }
356
+
357
+ // ------------------------------------------------------------------ 演示
358
+
359
+ function runDemo(opts) {
360
+ const json = opts.json;
361
+ const python = ensurePython(json);
362
+ const fitzInfo = ensureFitz(python, json);
363
+ const work = mkdtempSync(join(tmpdir(), 'dsh-pdf-kit-demo-'));
364
+ const outRoot = join(work, 'output');
365
+ const make = unwrap(runOp(python, { action: 'make-demo', dir: work }), 'make-demo', json);
366
+ const { textA, textB, heavy } = make.stats;
367
+ const steps = [];
368
+
369
+ const run = (step, job) => {
370
+ const res = unwrap(runOp(python, job), step, json);
371
+ steps.push({ step, outputs: res.outputs, stats: res.stats });
372
+ if (!json) printResult(step, res);
373
+ return res;
374
+ };
375
+
376
+ if (!json) {
377
+ console.log('');
378
+ console.log(' PDF 工具箱 · 演示模式(自造样例,不碰你的文件)');
379
+ console.log(` Python:${python} · PyMuPDF ${fitzInfo.fitz}`);
380
+ console.log(` 工作目录:${work}`);
381
+ console.log('');
382
+ }
383
+
384
+ run('merge', { action: 'merge', files: [textA, textB], outDir: join(outRoot, 'merge'), base: 'demo' });
385
+ run('split', { action: 'split', file: textA, outDir: join(outRoot, 'split-page'), base: 'demo-a', mode: 'page' });
386
+ run('split', { action: 'split', file: textA, outDir: join(outRoot, 'split-every'), base: 'demo-a', mode: 'every', n: 2 });
387
+ run('compress', { action: 'compress', file: heavy, outDir: join(outRoot, 'compress'), base: 'demo-heavy', quality: 60, dpi: 72 });
388
+ run('watermark', { action: 'watermark', file: textA, outDir: join(outRoot, 'watermark'), base: 'demo-a', text: 'DSH 深鲸示例水印', fontsize: 28, opacity: 0.16 });
389
+ run('extract-text', { action: 'extract-text', file: textA, outDir: join(outRoot, 'text'), base: 'demo-a' });
390
+ run('extract-images', { action: 'extract-images', file: textA, outDir: join(outRoot, 'images'), base: 'demo-a' });
391
+
392
+ if (json) {
393
+ emit(true, { ok: true, demo: true, workdir: work, python, fitz: fitzInfo.fitz, steps });
394
+ return;
395
+ }
396
+ console.log('');
397
+ console.log(`✅ 演示完成:7 个动作全部真跑通,产物共 ${steps.reduce((n, s) => n + s.outputs.length, 0)} 个`);
398
+ console.log(` 查看:${outRoot}`);
399
+ console.log(' 说明:演示文件在临时目录,可自行删除。');
400
+ }
401
+
402
+ // ------------------------------------------------------------------ 主流程
403
+
404
+ function main() {
405
+ const argv = process.argv.slice(2);
406
+ if (argv.length === 0) {
407
+ printHelp();
408
+ process.exit(0);
409
+ }
410
+ const opts = parseArgs(argv);
411
+
412
+ if (opts.help) {
413
+ printHelp();
414
+ process.exit(0);
415
+ }
416
+
417
+ if (opts.probe) {
418
+ const python = ensurePython(opts.json);
419
+ const info = ensureFitz(python, opts.json);
420
+ if (opts.json) {
421
+ emit(true, { ok: true, python, fitz: info.fitz });
422
+ } else {
423
+ console.log('');
424
+ console.log(' PDF 工具箱 · 本机能力探测');
425
+ console.log(` python : ${python}`);
426
+ console.log(` PyMuPDF: ${info.fitz}`);
427
+ console.log('');
428
+ }
429
+ process.exit(0);
430
+ }
431
+
432
+ if (opts.demo) {
433
+ runDemo(opts);
434
+ return;
435
+ }
436
+
437
+ // 子命令与动作
438
+ const positionals = [...opts.positionals];
439
+ if (positionals[0] === 'pdf') positionals.shift();
440
+ const cmd = positionals.shift();
441
+ if (!cmd) {
442
+ fail('缺少子命令', {
443
+ code: 2,
444
+ json: opts.json,
445
+ hint: `可用子命令:${Object.keys(COMMANDS).join(' / ')}\n用 ${CMD_NAME} --help 查看完整用法`,
446
+ });
447
+ }
448
+ const spec = COMMANDS[cmd];
449
+ if (!spec) {
450
+ fail(`未知子命令:${cmd}`, {
451
+ code: 2,
452
+ json: opts.json,
453
+ hint: `可用子命令:${Object.keys(COMMANDS).join(' / ')}\n用 ${CMD_NAME} --help 查看完整用法`,
454
+ extra: { command: cmd },
455
+ });
456
+ }
457
+ let action = null;
458
+ if (positionals.length > 0 && spec.actions.includes(positionals[0])) {
459
+ action = positionals.shift();
460
+ }
461
+ // 其余位置参数当作输入(方便 `merge a.pdf b.pdf`)
462
+ for (const p of positionals) opts.inputs.push(p);
463
+
464
+ if (opts.inputs.length === 0) {
465
+ fail('缺少 --input(或直接给出 PDF 路径)', {
466
+ code: 2,
467
+ json: opts.json,
468
+ hint: `例如:${CMD_NAME} ${cmd} --input <文件或目录>\n或先跑 ${CMD_NAME} --demo 看效果`,
469
+ });
470
+ }
471
+
472
+ // 选项校验
473
+ const intOpt = (name, raw, min, max, dflt) => {
474
+ if (raw == null) return dflt;
475
+ const v = Number(raw);
476
+ if (!Number.isInteger(v) || v < min || v > max) {
477
+ fail(`--${name} 需要 ${min}~${max} 的整数,收到:${raw}`, { code: 2, json: opts.json });
478
+ }
479
+ return v;
480
+ };
481
+ const floatOpt = (name, raw, min, max, dflt) => {
482
+ if (raw == null) return dflt;
483
+ const v = Number(raw);
484
+ if (!Number.isFinite(v) || v < min || v > max) {
485
+ fail(`--${name} 需要 ${min}~${max} 的数值,收到:${raw}`, { code: 2, json: opts.json });
486
+ }
487
+ return v;
488
+ };
489
+
490
+ let mode = 'page';
491
+ let n = null;
492
+ if (cmd === 'split') {
493
+ if (action) mode = spec.modes[action] || 'page';
494
+ if (opts.n != null) { mode = 'every'; n = intOpt('n', opts.n, 1, 100000, 1); }
495
+ if (mode === 'every' && n == null) n = 2;
496
+ }
497
+ if (cmd === 'watermark' && !opts.text) {
498
+ fail('watermark 缺少 --text(水印文字)', {
499
+ code: 2,
500
+ json: opts.json,
501
+ hint: '例如:dsh-pdf-kit watermark --text "内部资料" --input a.pdf',
502
+ });
503
+ }
504
+
505
+ const quality = intOpt('quality', opts.quality, 1, 95, 65);
506
+ const dpi = intOpt('dpi', opts.dpi, 36, 600, 96);
507
+ const fontsize = floatOpt('fontsize', opts.fontsize, 6, 200, 30);
508
+ const opacity = floatOpt('opacity', opts.opacity, 0.01, 1, 0.16);
509
+ const minBytes = intOpt('min-bytes', opts.minBytes, 0, 1e9, 0);
510
+
511
+ const files = collectInputs(opts, opts.json);
512
+ if (cmd === 'merge' && files.length < 2) {
513
+ fail(`merge 至少需要 2 个 PDF,当前只有 ${files.length} 个`, {
514
+ code: 2,
515
+ json: opts.json,
516
+ hint: '用 --input a.pdf,b.pdf 或把多个 PDF 放进同一个目录后传目录',
517
+ extra: { files },
518
+ });
519
+ }
520
+
521
+ const python = ensurePython(opts.json);
522
+ const fitzInfo = ensureFitz(python, opts.json);
523
+ const outDir = defaultOutDir(files, opts);
524
+ ensureDir(outDir, opts.json);
525
+
526
+ if (!opts.json) {
527
+ console.log('');
528
+ console.log(` PDF 工具箱 · ${cmd}${mode === 'every' && cmd === 'split' ? `(每 ${n} 页)` : ''}`);
529
+ console.log(` 输入:${files.length} 个文件`);
530
+ console.log(` 输出:${outDir}`);
531
+ console.log(` 环境:${python} · PyMuPDF ${fitzInfo.fitz}`);
532
+ console.log('');
533
+ }
534
+
535
+ const results = [];
536
+ if (cmd === 'merge') {
537
+ const job = {
538
+ action: 'merge', files, outDir, base: stemOf(files[0]),
539
+ };
540
+ const res = unwrap(runOp(python, job), 'merge', opts.json);
541
+ results.push({ step: 'merge', inputs: files, ...res });
542
+ if (!opts.json) printResult('merge', res);
543
+ } else {
544
+ for (const file of files) {
545
+ let job;
546
+ if (cmd === 'split') {
547
+ job = { action: 'split', file, outDir, base: stemOf(file), mode, n };
548
+ } else if (cmd === 'compress') {
549
+ job = { action: 'compress', file, outDir, base: stemOf(file), quality, dpi };
550
+ } else if (cmd === 'watermark') {
551
+ job = { action: 'watermark', file, outDir, base: stemOf(file), text: opts.text, fontsize, opacity };
552
+ } else if (cmd === 'extract-text') {
553
+ job = { action: 'extract-text', file, outDir, base: stemOf(file) };
554
+ } else if (cmd === 'extract-images') {
555
+ job = { action: 'extract-images', file, outDir, base: stemOf(file), minBytes };
556
+ }
557
+ const res = unwrap(runOp(python, job), cmd, opts.json);
558
+ results.push({ step: cmd, input: file, ...res });
559
+ if (!opts.json) printResult(cmd, res);
560
+ }
561
+ }
562
+
563
+ if (opts.json) {
564
+ const outputs = results.flatMap((r) => r.outputs || []);
565
+ console.log(JSON.stringify({
566
+ ok: true,
567
+ command: cmd,
568
+ action,
569
+ outDir,
570
+ files: files.length,
571
+ count: outputs.length,
572
+ outputs,
573
+ results,
574
+ }));
575
+ } else {
576
+ console.log('');
577
+ console.log(`✅ 全部完成:${results.reduce((acc, r) => acc + (r.outputs?.length || 0), 0)} 个产物 · 输入未改动`);
578
+ }
579
+ }
580
+
581
+ main();