@deepwhale-cn/dsh-pptx-batch 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 深鲸 DeepWhale
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,100 @@
1
- # Temporary Holding Version
1
+ # @deepwhale-cn/dsh-pptx-batch
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/feely0208/dsh-pptx-batch)
4
+
5
+ **整目录的 PPT 一次处理完**:批量填模板占位符、按形状名换图片、提取每页文本、看页数/形状数/图片数 —— 不打开 PowerPoint,不动原文件。
6
+
7
+ **关键词**:PPT 批量填模板 · pptx 批量替换 · PPT 批量换 Logo · 提取 PPT 文本 · PPT 页数统计 · 汇报批量生成 · 模板占位符替换 · 命令行处理 PPT
8
+
9
+ ## 不装会怎样
10
+
11
+ 30 份给不同客户的报价 PPT,模板里写着 `{{客户}}`、`{{金额}}`:不装就得一份份打开、双击文本框、输入、保存、关掉,
12
+ 一小时起步,改到第 20 份时手一滑把上一位客户的名字留在下一份里,也没人发现。
13
+
14
+ 五十份材料的 Logo 要换新版:人工做法是逐份删图、插图、再对齐一遍位置,版面还容易歪。
15
+
16
+ 要核对一批 PPT 每页写了什么、各有多少页多少张图:只能一份份翻,翻完还记不住。
17
+
18
+ 这些都属于「机械但量大、还容易漏」的活。更麻烦的是直接让 AI 改原文件,改坏了没有退路 ——
19
+ 这个插件**只读输入**,产物写到单独的输出目录,同名文件自动加序号,不覆盖任何东西。
20
+
21
+ ## 能做什么
22
+
23
+ | 子命令 | 作用 | 产物(可核对) |
24
+ |---|---|---|
25
+ | `fill` | 按 `--set key=value` 批量填幻灯片里的 `{{key}}` 占位符(文本框、占位符、表格单元格、组合形状) | 填好的 `.pptx` 副本;JSON 里给出每份替换了几处、哪个 key 几处 |
26
+ | `replace-image` | 把名为 `--shape` 的图片形状换成 `--image` 指定的图片 | 换图后的 `.pptx` 副本;图片数量不变、位置尺寸不变、形状名不变 |
27
+ | `extract-text` | 提取每页文本:标题 + 正文 + 表格 + 备注 | 每份 PPT 一个同名 `.txt`,按「第 N 页」分块 |
28
+ | `info` | 输出每份 PPT 的页数、形状数、图片数 | 终端表格;`--json` 给脚本用 |
29
+
30
+ 共同约定:
31
+
32
+ - **不修改输入文件**,默认输出到 `<输入目录>/_输出`,可用 `--out` 指定;
33
+ - **同名文件不覆盖**:已存在时自动变成 `简报_1.pptx`、`简报_2.pptx`;
34
+ - 目录递归处理,自动跳过 PowerPoint 的 `~$` 临时文件和 `_输出` 目录;
35
+ - `--json` 输出结构化结果,方便脚本 / CI 断言;出错时退出码非 0 并说明缺什么;
36
+ - `fill` 填完会报出**还剩哪些 `{{...}}` 没给值**,模板里拼错 key 一眼能看出来。
37
+
38
+ ```bash
39
+ # 批量填模板:一份模板 + 一个客户一套值
40
+ dsh-pptx-batch fill --input ./模板 --out ./已填 \
41
+ --set 客户=客户甲 --set 金额=128000 --set 日期=2026-10-24
42
+
43
+ # 批量换 Logo(形状名要和 PowerPoint「选择窗格」里显示的一致)
44
+ dsh-pptx-batch replace-image --input ./已填 --out ./换图 \
45
+ --shape Logo --image ./logo-新.png
46
+
47
+ # 提取每页文本
48
+ dsh-pptx-batch extract-text --input ./模板 --out ./文本
49
+
50
+ # 看一眼结构
51
+ dsh-pptx-batch info --input ./模板 --json
52
+ ```
53
+
54
+ `fill` 保留格式的方式:占位符整段落在一个 run 里时,这个 run 的字体、字号、颜色完全不动;
55
+ 只有跨多个 run 的占位符(同一段字被 PowerPoint 拆成几块)才会把这几段并到起始 run 的格式上。
56
+
57
+ ## 不能做什么
58
+
59
+ - ❌ 只支持 `.pptx`;`.ppt` 老格式、`.pptm`、加密/需要密码打开的文档不支持 —— `.ppt` 请先用 PowerPoint/WPS 另存为 `.pptx`。
60
+ - ❌ `fill` **不处理**图表文字、SmartArt 文字、图片里的文字、批注、母版/版式上的文字,也不跨段落匹配。
61
+ - ❌ `fill` 是**字面匹配**(`{{key}}`,key 大小写敏感,允许 `{{ key }}` 这种空格),不支持正则、不支持一次给同名 key 多个值。
62
+ - ❌ **不保证排版 100% 不变**:跨 run 的占位符替换会合并该段文字的 run,段落内局部加粗/字号/颜色可能丢失;
63
+ 重要模板请先跑一份对比再批量用。
64
+ - ❌ `replace-image` 只换图片内容:新图比例和原图不同时会被**拉伸**(裁剪设置沿用原来的);
65
+ 只按形状名精确匹配,不支持按内容或页码挑选,同名形状会全部替换。
66
+ - ❌ `extract-text` 不提取图表数据、SmartArt、页码与母版文字;图片只留位置不留文字(没有 OCR)。
67
+ - ❌ `info` 的「形状数」是每页顶层形状数之和(组合形状算 1 个,其内部形状不计入)。
68
+ - ❌ 不是格式转换器:不导出 PDF、不做 OCR、不做批量重命名。
69
+ - ✅ 全部在本机由 python-pptx 处理,不联网、不上传文件。
70
+
71
+ ## 安装
72
+
73
+ ```bash
74
+ dsh plugin --profile web add @deepwhale-cn/dsh-pptx-batch
75
+ ```
76
+
77
+ 运行依赖本机的 **python3 + python-pptx**(插件不会替你装)。缺依赖时 CLI 会直接打印安装命令:
78
+
79
+ ```bash
80
+ python3 -m pip install python-pptx
81
+ # macOS 没有 python3 时
82
+ brew install python3
83
+ ```
84
+
85
+ ## 用法
86
+
87
+ ```bash
88
+ dsh-pptx-batch --help
89
+ dsh-pptx-batch --demo # 自造样例 PPT 跑完整流程,打印产物路径
90
+ dsh-pptx-batch <action> --input <文件或目录> [--out <目录>] [--json]
91
+ ```
92
+
93
+ `--demo` 会在系统临时目录里造两份带占位符、表格和图片形状的样例 PPT,
94
+ 依次跑 fill / replace-image / extract-text / info,不动你的任何文件,跑完直接看产物路径就知道它能干什么。
95
+
96
+ 退出码:`0` 成功 · `1` 运行失败 · `2` 参数或输入问题 · `3` 缺依赖。
97
+
98
+ ## License
99
+
100
+ MIT © 温州深鲸智能科技有限公司
package/bin/cli.mjs ADDED
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PPT 批量处理 CLI
4
+ *
5
+ * 形态:
6
+ * dsh-pptx-batch --help (无参数也打印 help,退出 0)
7
+ * dsh-pptx-batch --demo (自造样例跑完整流程,退出 0)
8
+ * dsh-pptx-batch <action> --input <文件或目录> [--out <目录>] [--json]
9
+ *
10
+ * 真实逻辑全部委托 python3 + python-pptx(lib/pptx_tool.py),
11
+ * Node 只负责参数校验、调用与人类可读输出。
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const PY_SCRIPT = join(PKG_ROOT, 'lib', 'pptx_tool.py');
19
+ const TITLE = 'PPT 批量处理';
20
+ const CMD = 'dsh-pptx-batch';
21
+ const ACTIONS = {
22
+ fill: '按 --set key=value 批量填模板里的 {{key}} 占位符',
23
+ 'replace-image': '把名为 --shape 的图片形状换成 --image 指定的图片',
24
+ 'extract-text': '提取每页文本(标题 + 正文 + 表格)到同名 .txt',
25
+ info: '输出每份 PPT 的页数 / 形状数 / 图片数',
26
+ };
27
+ const PY_HINT = [
28
+ '需要 python3 + python-pptx。安装:',
29
+ ' python3 -m pip install python-pptx',
30
+ '没有 python3 时:',
31
+ ' macOS: brew install python3',
32
+ ' Ubuntu: sudo apt install python3',
33
+ ].join('\n');
34
+ const LINE = '─'.repeat(56);
35
+
36
+ function help() {
37
+ console.log(`${TITLE}
38
+
39
+ 用法:
40
+ ${CMD} <action> --input <文件或目录> [--out <目录>] [--json]
41
+ ${CMD} --demo
42
+ ${CMD} --help
43
+
44
+ 子命令:
45
+ ${Object.entries(ACTIONS).map(([k, v]) => ` ${k.padEnd(14)}${v}`).join('\n')}
46
+
47
+ 参数:
48
+ --input, -i .pptx 文件或目录(目录递归处理)
49
+ --out, -o 输出目录;默认 <输入目录>/_输出;同名文件不覆盖,自动加序号
50
+ --set key=value,可重复;对应幻灯片里的 {{key}} 占位符
51
+ --shape 要替换的图片形状名(精确匹配,大小写敏感)
52
+ --image 新图片文件(png / jpg / gif / bmp / tiff)
53
+ --json 输出 JSON(给脚本 / CI 用)
54
+ --demo 自造样例 PPT,跑完整流程并打印产物路径
55
+
56
+ 示例:
57
+ ${CMD} fill --input ./模板 --set 客户=客户甲 --set 金额=128000
58
+ ${CMD} replace-image --input ./已填 --shape Logo --image ./logo-新.png
59
+ ${CMD} extract-text --input ./模板 --out ./文本
60
+ ${CMD} info --input ./模板 --json
61
+
62
+ 依赖:python3 + python-pptx(缺失时会提示安装命令)`);
63
+ process.exit(0);
64
+ }
65
+
66
+ function fail(message, hint = '', code = 2, json = false) {
67
+ if (json) {
68
+ console.log(JSON.stringify({ ok: false, code, message, hint }, null, 2));
69
+ } else {
70
+ console.error(`❌ ${message}`);
71
+ if (hint) console.error(`\n${hint}`);
72
+ }
73
+ process.exit(code);
74
+ }
75
+
76
+ function findPython() {
77
+ const candidates = [
78
+ process.env.DSH_PYTHON,
79
+ process.env.PPTX_BATCH_PYTHON,
80
+ '/opt/homebrew/bin/python3',
81
+ 'python3',
82
+ '/usr/local/bin/python3',
83
+ '/usr/bin/python3',
84
+ ].filter(Boolean);
85
+ for (const cmd of candidates) {
86
+ const r = spawnSync(cmd, ['-c', 'import sys; print(sys.version_info[0])'], {
87
+ encoding: 'utf8',
88
+ timeout: 10000,
89
+ });
90
+ if (!r.error && r.status === 0) return cmd;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ function parseArgs(argv) {
96
+ const opt = {
97
+ action: null, input: null, out: null, set: [], shape: null, image: null,
98
+ json: false, demo: false,
99
+ };
100
+ const VALUE_FLAGS = new Set(['--input', '-i', '--out', '-o', '--set', '--shape', '--image']);
101
+ for (let i = 0; i < argv.length; i++) {
102
+ const raw = argv[i];
103
+ const eq = raw.indexOf('=');
104
+ const flag = eq > 0 ? raw.slice(0, eq) : raw;
105
+ const inline = eq > 0 ? raw.slice(eq + 1) : undefined;
106
+ if (flag === '--json') {
107
+ opt.json = true;
108
+ continue;
109
+ }
110
+ if (flag === '--demo') {
111
+ opt.demo = true;
112
+ continue;
113
+ }
114
+ if (flag === '--help' || flag === '-h') help();
115
+ if (VALUE_FLAGS.has(flag)) {
116
+ let value = inline;
117
+ if (value === undefined) {
118
+ value = argv[i + 1];
119
+ if (value === undefined || value.startsWith('--')) {
120
+ fail(`参数 ${flag} 缺少取值`, `例:${flag} <值>`);
121
+ }
122
+ i += 1;
123
+ }
124
+ if (flag === '--input' || flag === '-i') opt.input = value;
125
+ else if (flag === '--out' || flag === '-o') opt.out = value;
126
+ else if (flag === '--shape') opt.shape = value;
127
+ else if (flag === '--image') opt.image = value;
128
+ else opt.set.push(value);
129
+ continue;
130
+ }
131
+ if (flag.startsWith('-')) {
132
+ fail(`未知参数:${raw}`, `用 ${CMD} --help 查看支持的参数。`);
133
+ }
134
+ if (!opt.action) {
135
+ opt.action = raw;
136
+ continue;
137
+ }
138
+ fail(`多余的参数:${raw}`, '一次只能执行一个子命令。');
139
+ }
140
+ return opt;
141
+ }
142
+
143
+ function human(res) {
144
+ console.log('');
145
+ console.log(` ${TITLE} · ${res.action}`);
146
+ console.log(` ${LINE}`);
147
+ if (res.input) console.log(` 输入 ${res.input}`);
148
+ if (res.out) console.log(` 输出目录 ${res.out}`);
149
+ if (res.image) console.log(` 新图片 ${res.image}`);
150
+ console.log('');
151
+ for (const f of res.files || []) {
152
+ switch (res.action) {
153
+ case 'fill': {
154
+ const keys = Object.entries(f.keys || {})
155
+ .map(([k, v]) => `${k}×${v}`).join(' ');
156
+ console.log(` ✓ ${f.input_name} → ${f.output_name} · 替换 ${f.replacements} 处${keys ? `(${keys})` : ''}`);
157
+ break;
158
+ }
159
+ case 'replace-image':
160
+ if (f.replaced === 0) {
161
+ console.log(` – ${f.input_name} → ${f.output_name} · 跳过(没有该形状)`);
162
+ } else {
163
+ console.log(` ✓ ${f.input_name} → ${f.output_name} · 替换图片 ${f.replaced} 处`);
164
+ }
165
+ break;
166
+ case 'extract-text':
167
+ console.log(` ✓ ${f.input_name} → ${f.output_name} · ${f.slides} 页 · ${f.chars} 字符`);
168
+ break;
169
+ case 'info':
170
+ console.log(` ✓ ${f.input_name} · ${f.pages} 页 · ${f.shapes} 个形状 · ${f.pictures} 张图片`);
171
+ break;
172
+ default:
173
+ console.log(` ✓ ${f.input_name} → ${f.output_name}`);
174
+ }
175
+ }
176
+ if (res.unfilled && res.unfilled.length) {
177
+ console.log(`\n ⚠ 还有 ${res.unfilled.length} 个占位符没给值:${res.unfilled.join('、')}`);
178
+ console.log(' 确认 --set 的 key 和模板里的 {{key}} 完全一致(大小写、空格)。');
179
+ }
180
+ if (res.summary) console.log(`\n ${res.summary}`);
181
+ if (res.artifacts && res.artifacts.length) {
182
+ console.log('\n 产物:');
183
+ for (const a of res.artifacts) console.log(` ${a}`);
184
+ }
185
+ console.log('');
186
+ }
187
+
188
+ function humanDemo(res) {
189
+ console.log('');
190
+ console.log(` ${TITLE} · 演示(--demo)`);
191
+ console.log(` ${LINE}`);
192
+ console.log(` 演示目录 ${res.demoRoot}`);
193
+ console.log('');
194
+ for (const s of res.steps || []) {
195
+ console.log(` ✓ ${String(s.step).padEnd(6)} ${s.action.padEnd(14)} ${s.summary}`);
196
+ }
197
+ console.log('\n 产物:');
198
+ for (const a of res.artifacts || []) console.log(` ${a}`);
199
+ console.log('');
200
+ }
201
+
202
+ function callPython(py, action, payload) {
203
+ const r = spawnSync(py, [PY_SCRIPT, action, JSON.stringify(payload)], {
204
+ encoding: 'utf8',
205
+ maxBuffer: 128 * 1024 * 1024,
206
+ });
207
+ if (r.error) {
208
+ fail(`调用 python 失败:${r.error.message}`, PY_HINT, 3);
209
+ }
210
+ const lines = (r.stdout || '').trim().split('\n').filter(Boolean);
211
+ let result = null;
212
+ try {
213
+ result = JSON.parse(lines[lines.length - 1] || '');
214
+ } catch {
215
+ /* 下面统一报错 */
216
+ }
217
+ if (!result) {
218
+ const detail = (r.stderr || '').trim().split('\n').slice(-5).join('\n');
219
+ fail('Python 处理失败(没有拿到有效结果)', detail || PY_HINT, r.status || 1);
220
+ }
221
+ return result;
222
+ }
223
+
224
+ const argv = process.argv.slice(2);
225
+ if (argv.length === 0) help();
226
+ if (argv.includes('--help') || argv.includes('-h')) help();
227
+
228
+ const opt = parseArgs(argv);
229
+ const python = findPython();
230
+ if (!python) {
231
+ fail('未找到 python3,无法处理 PPT 文件', PY_HINT, 3, opt.json);
232
+ }
233
+
234
+ if (opt.demo) {
235
+ const res = callPython(python, 'demo', {});
236
+ if (!res.ok) fail(res.message, res.hint, res.code || 1, opt.json);
237
+ if (opt.json) console.log(JSON.stringify(res, null, 2));
238
+ else humanDemo(res);
239
+ process.exit(0);
240
+ }
241
+
242
+ if (!opt.action) {
243
+ fail('缺少子命令', `可用子命令:${Object.keys(ACTIONS).join(' / ')}\n用 ${CMD} --help 查看详情。`, 2, opt.json);
244
+ }
245
+ if (!Object.hasOwn(ACTIONS, opt.action)) {
246
+ fail(`未知子命令:${opt.action}`, `可用子命令:${Object.keys(ACTIONS).join(' / ')}`, 2, opt.json);
247
+ }
248
+ if (!opt.input) {
249
+ fail('缺少 --input(文件或目录)', `例:${CMD} ${opt.action} --input ./PPT目录`, 2, opt.json);
250
+ }
251
+ if (opt.action === 'fill') {
252
+ if (opt.set.length === 0) {
253
+ fail('缺少 --set(要填的占位符)',
254
+ `例:${CMD} fill --input ./模板 --set 客户=客户甲 --set 金额=128000`, 2, opt.json);
255
+ }
256
+ const bad = opt.set.find((s) => !s.includes('=') || !s.split('=')[0].trim());
257
+ if (bad !== undefined) {
258
+ fail(`--set 需要 key=value 形式:${bad}`, '例:--set 客户=客户甲', 2, opt.json);
259
+ }
260
+ }
261
+ if (opt.action === 'replace-image') {
262
+ if (!opt.shape) {
263
+ fail('缺少 --shape(要替换的图片形状名)',
264
+ `例:${CMD} replace-image --input ./模板 --shape Logo --image ./logo.png`, 2, opt.json);
265
+ }
266
+ if (!opt.image) {
267
+ fail('缺少 --image(新图片文件)',
268
+ `例:${CMD} replace-image --input ./模板 --shape Logo --image ./logo.png`, 2, opt.json);
269
+ }
270
+ }
271
+
272
+ const payload = {
273
+ input: opt.input,
274
+ out: opt.out,
275
+ set: opt.set,
276
+ shape: opt.shape,
277
+ image: opt.image,
278
+ };
279
+ const res = callPython(python, opt.action, payload);
280
+ if (!res.ok) fail(res.message, res.hint, res.code || 1, opt.json);
281
+
282
+ if (opt.json) console.log(JSON.stringify(res, null, 2));
283
+ else human(res);
284
+ process.exit(0);
@@ -0,0 +1,4 @@
1
+ # @deepwhale-cn/dsh-pptx-batch 的组合层:装上即注册 pptx-batch skill
2
+ - insert:
3
+ - id: pptx-batch
4
+ name: '@deepwhale-cn/dsh-pptx-batch'
package/lib/index.js ADDED
@@ -0,0 +1,10 @@
1
+ import { createPlugin, pkgRootFrom } from '@deepwhale-cn/dsh-guard-core/plugin';
2
+
3
+ export const { name, inject, apply } = createPlugin({
4
+ name: 'pptx-batch',
5
+ pkgRoot: pkgRootFrom(import.meta.url),
6
+ skillFile: 'skill/pptx-batch.md',
7
+ binFile: 'bin/cli.mjs',
8
+ description: 'PPT 批量填模板 / 替换文字与图片 / 导出内容清单(python-pptx)',
9
+ whenToUse: '当用户要批量处理整目录的 PowerPoint(.pptx)时使用:按 {{key}} 批量填模板(客户名、金额、日期)、按形状名批量换图片(Logo)、提取每页文本到 txt、统计页数/形状数/图片数。典型说法:「这套模板有 30 份要按客户发」「把每份 PPT 里的旧 Logo 换掉」「把这批 pptx 的文字导出来核对」。不用于 .ppt 老格式、加密文档、需要保持复杂动画与版式的场景。',
10
+ });
@@ -0,0 +1,705 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ dsh-pptx-batch 的真实处理逻辑(python-pptx)
5
+
6
+ 由 bin/cli.mjs 通过 spawnSync 调用:
7
+ python3 lib/pptx_tool.py <action> '<json payload>'
8
+
9
+ 约定:
10
+ - stdout 只输出一行 JSON(人类可读文案由 Node 侧负责)
11
+ - stderr 输出异常栈(调试用)
12
+ - 退出码:0 成功 / 1 运行失败 / 2 参数或输入问题 / 3 缺依赖
13
+ - 只读输入文件,所有产物写进 --out 目录(默认 <输入目录>/_输出)
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import shutil
20
+ import struct
21
+ import sys
22
+ import tempfile
23
+ import traceback
24
+ import zlib
25
+ from pathlib import Path
26
+
27
+ try:
28
+ from pptx import Presentation
29
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
30
+ from pptx.util import Inches
31
+ except ImportError as exc: # 缺依赖必须明确提示安装命令,不能静默
32
+ print(json.dumps({
33
+ "ok": False,
34
+ "code": 3,
35
+ "message": "缺少 Python 依赖:%s" % getattr(exc, "name", exc),
36
+ "hint": "请先安装:\n python3 -m pip install python-pptx\n"
37
+ "如果 python3 也没有:\n macOS: brew install python3\n Ubuntu: sudo apt install python3",
38
+ }, ensure_ascii=False))
39
+ sys.exit(3)
40
+
41
+ PPTX_EXT = ".pptx"
42
+ SKIP_DIRS = {"_输出", "node_modules", ".git", "__pycache__", ".venv", "venv"}
43
+ PLACEHOLDER_RE = re.compile(r"\{\{\s*([^{}]{1,60}?)\s*\}\}")
44
+ DEMO_TITLE = "季度经营简报"
45
+ DEMO_CLIENT = "示例客户甲"
46
+ DEMO_AMOUNT = "¥ 128,000"
47
+
48
+
49
+ # ── 错误 ──────────────────────────────────────────────────────────
50
+
51
+ class ToolError(Exception):
52
+ def __init__(self, message, hint="", code=2):
53
+ super().__init__(message)
54
+ self.message = message
55
+ self.hint = hint
56
+ self.code = code
57
+
58
+
59
+ def emit(obj):
60
+ print(json.dumps(obj, ensure_ascii=False))
61
+
62
+
63
+ # ── 路径与文件收集 ────────────────────────────────────────────────
64
+
65
+ def natural_key(text):
66
+ return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", text)]
67
+
68
+
69
+ def resolve_out(payload, input_path):
70
+ """输出目录:--out 优先,否则 <输入目录>/_输出。"""
71
+ out = payload.get("out")
72
+ if out:
73
+ return Path(out).expanduser().resolve()
74
+ base = Path(input_path).expanduser().resolve()
75
+ if not base.is_dir():
76
+ base = base.parent
77
+ return base / "_输出"
78
+
79
+
80
+ def collect_pptx(input_path, out_dir=None):
81
+ """接受单个 .pptx 或目录(递归),按文件名自然排序。"""
82
+ if not input_path:
83
+ raise ToolError("缺少 --input", "例:fill --input ./模板目录 --set 客户=客户甲")
84
+ root = Path(input_path).expanduser().resolve()
85
+ if not root.exists():
86
+ raise ToolError(
87
+ "输入不存在:%s" % input_path,
88
+ "请检查路径是否正确;传目录会递归处理其中的 .pptx 文件。",
89
+ )
90
+ skip_out = set()
91
+ if out_dir:
92
+ try:
93
+ skip_out = {str(Path(out_dir).resolve())}
94
+ except OSError:
95
+ skip_out = set()
96
+ if root.is_file():
97
+ if root.suffix.lower() != PPTX_EXT:
98
+ raise ToolError(
99
+ "输入不是 .pptx 文件:%s" % root.name,
100
+ "本插件只处理 PowerPoint 2007+ 的 .pptx;.ppt 请先另存为 .pptx。",
101
+ )
102
+ return [root]
103
+ files = []
104
+ for dirpath, dirnames, filenames in os.walk(root):
105
+ current = str(Path(dirpath).resolve())
106
+ if current in skip_out:
107
+ dirnames[:] = []
108
+ continue
109
+ dirnames[:] = sorted(
110
+ [d for d in dirnames if d not in SKIP_DIRS and not d.startswith("~$")],
111
+ key=natural_key,
112
+ )
113
+ for name in filenames:
114
+ if name.startswith("~$"):
115
+ continue # PowerPoint 打开时的临时文件
116
+ p = Path(dirpath) / name
117
+ if p.suffix.lower() == PPTX_EXT:
118
+ files.append(p)
119
+ files.sort(key=lambda p: natural_key(str(p.relative_to(root))))
120
+ if not files:
121
+ raise ToolError(
122
+ "目录里没有 .pptx 文件:%s" % root,
123
+ "确认目录选对了;本插件不处理 .ppt(请先另存为 .pptx)。",
124
+ )
125
+ return files
126
+
127
+
128
+ def unique_path(directory, filename):
129
+ """同名文件不覆盖,追加 _1/_2 序号。"""
130
+ directory = Path(directory)
131
+ target = directory / filename
132
+ if not target.exists():
133
+ return target
134
+ stem, suffix = target.stem, target.suffix
135
+ n = 1
136
+ while (directory / ("%s_%d%s" % (stem, n, suffix))).exists():
137
+ n += 1
138
+ return directory / ("%s_%d%s" % (stem, n, suffix))
139
+
140
+
141
+ # ── 形状遍历 ──────────────────────────────────────────────────────
142
+
143
+ def shape_type_of(shape):
144
+ try:
145
+ return shape.shape_type
146
+ except Exception:
147
+ return None
148
+
149
+
150
+ def is_group(shape):
151
+ return shape_type_of(shape) == MSO_SHAPE_TYPE.GROUP
152
+
153
+
154
+ def is_picture(shape):
155
+ return shape_type_of(shape) == MSO_SHAPE_TYPE.PICTURE
156
+
157
+
158
+ def iter_shapes(shapes):
159
+ """按文档顺序遍历形状,组合形状递归展开(只读遍历,不改变结构)。"""
160
+ for shape in shapes:
161
+ yield shape
162
+ if is_group(shape):
163
+ try:
164
+ yield from iter_shapes(shape.shapes)
165
+ except Exception:
166
+ continue # 极少数残缺组合形状取不到子形状:跳过
167
+
168
+
169
+ def shape_kind(shape):
170
+ if getattr(shape, "has_table", False):
171
+ return "table"
172
+ if getattr(shape, "has_chart", False):
173
+ return "chart"
174
+ if is_picture(shape):
175
+ return "picture"
176
+ if is_group(shape):
177
+ return "group"
178
+ if getattr(shape, "has_text_frame", False):
179
+ return "text"
180
+ return "shape"
181
+
182
+
183
+ def shape_paragraphs(shape):
184
+ """形状里可编辑的段落:文本框 + 表格单元格。"""
185
+ if getattr(shape, "has_text_frame", False):
186
+ yield from shape.text_frame.paragraphs
187
+ if getattr(shape, "has_table", False):
188
+ for row in shape.table.rows:
189
+ for cell in row.cells:
190
+ yield from cell.text_frame.paragraphs
191
+
192
+
193
+ def iter_paragraphs(prs):
194
+ """整份 PPT 里所有可编辑段落(每页的文本框、表格单元格,组合形状递归)。"""
195
+ for slide in prs.slides:
196
+ for shape in iter_shapes(slide.shapes):
197
+ yield from shape_paragraphs(shape)
198
+
199
+
200
+ # ── 文本替换(尽量保留 run 级格式) ───────────────────────────────
201
+
202
+ def sub_paragraph(paragraph, pattern, replacement):
203
+ """
204
+ 在单个段落里按正则替换,返回替换次数。
205
+
206
+ 先按 run 拼出整段文字再定位匹配,然后把「匹配外的字符」原样归还原来的 run,
207
+ 「替换后的文字」落到匹配起点所在的 run 上 —— 所以没被匹配碰到的 run 格式完全不变,
208
+ 只有真正跨 run 的匹配才会把这几段文字并到起始 run 的格式上。
209
+ """
210
+ runs = list(paragraph.runs)
211
+ if not runs:
212
+ return 0
213
+ texts = [run.text for run in runs]
214
+ full = "".join(texts)
215
+ if not full:
216
+ return 0
217
+ matches = list(pattern.finditer(full))
218
+ if not matches:
219
+ return 0
220
+ owner = []
221
+ for idx, text in enumerate(texts):
222
+ owner.extend([idx] * len(text))
223
+ pieces = [[] for _ in runs]
224
+ cursor = 0
225
+ for match in matches:
226
+ start, end = match.span()
227
+ if end <= cursor:
228
+ continue
229
+ if start > cursor:
230
+ for pos in range(cursor, start):
231
+ pieces[owner[pos]].append(full[pos])
232
+ pieces[owner[start]].append(replacement)
233
+ cursor = end
234
+ for pos in range(cursor, len(full)):
235
+ pieces[owner[pos]].append(full[pos])
236
+ for run, old, parts in zip(runs, texts, pieces):
237
+ new = "".join(parts)
238
+ if new != old:
239
+ run.text = new
240
+ return len(matches)
241
+
242
+
243
+ def paragraph_text(paragraph):
244
+ try:
245
+ return paragraph.text
246
+ except Exception:
247
+ return ""
248
+
249
+
250
+ def leftover_placeholders(prs):
251
+ """填完之后还剩哪些 {{...}} —— 帮用户抓模板里拼错的 key。"""
252
+ left = []
253
+ for paragraph in iter_paragraphs(prs):
254
+ for match in PLACEHOLDER_RE.finditer(paragraph_text(paragraph)):
255
+ key = match.group(1).strip()
256
+ if key and key not in left:
257
+ left.append(key)
258
+ return left
259
+
260
+
261
+ # ── 各动作 ────────────────────────────────────────────────────────
262
+
263
+ def parse_sets(raw_sets):
264
+ if not raw_sets:
265
+ raise ToolError(
266
+ "缺少 --set(要填的占位符)",
267
+ "每个值用 key=value,可重复:\n"
268
+ " fill --input ./模板 --set 客户=客户甲 --set 金额=1000",
269
+ )
270
+ pairs, seen = [], set()
271
+ for item in raw_sets:
272
+ if "=" not in item:
273
+ raise ToolError(
274
+ "--set 需要 key=value 形式:%s" % item,
275
+ "例:--set 客户=客户甲(等号左边是模板里的 {{key}},右边是要填的值)",
276
+ )
277
+ key, value = item.split("=", 1)
278
+ key = key.strip()
279
+ if not key:
280
+ raise ToolError(
281
+ "--set 的 key 不能为空:%s" % item,
282
+ "例:--set 客户=客户甲",
283
+ )
284
+ if key in seen:
285
+ raise ToolError(
286
+ "--set 重复给了同一个 key:%s" % key,
287
+ "同一个 key 只写一次,需要不同值请分两次运行。",
288
+ )
289
+ seen.add(key)
290
+ pairs.append((key, value))
291
+ return pairs
292
+
293
+
294
+ def action_fill(payload):
295
+ files = collect_pptx(payload.get("input"))
296
+ out = resolve_out(payload, payload.get("input"))
297
+ pairs = parse_sets(payload.get("set"))
298
+ out.mkdir(parents=True, exist_ok=True)
299
+ results, total, unfilled = [], 0, []
300
+ for src in files:
301
+ prs = Presentation(str(src))
302
+ counts = {}
303
+ for key, value in pairs:
304
+ pattern = re.compile(r"\{\{\s*" + re.escape(key) + r"\s*\}\}")
305
+ for paragraph in iter_paragraphs(prs):
306
+ n = sub_paragraph(paragraph, pattern, value)
307
+ if n:
308
+ counts[key] = counts.get(key, 0) + n
309
+ file_total = sum(counts.values())
310
+ left = leftover_placeholders(prs)
311
+ for key in left:
312
+ if key not in unfilled:
313
+ unfilled.append(key)
314
+ target = unique_path(out, src.name)
315
+ prs.save(str(target))
316
+ results.append({
317
+ "input": str(src),
318
+ "input_name": src.name,
319
+ "output": str(target),
320
+ "output_name": target.name,
321
+ "replacements": file_total,
322
+ "keys": counts,
323
+ "unfilled": left,
324
+ })
325
+ total += file_total
326
+ summary = "处理 %d 个文件,共替换 %d 处占位符" % (len(files), total)
327
+ if unfilled:
328
+ summary += ";仍有未赋值占位符 %d 个:%s" % (len(unfilled), "、".join(unfilled[:8]))
329
+ return {
330
+ "action": "fill",
331
+ "input": str(Path(payload["input"]).expanduser().resolve()),
332
+ "out": str(out),
333
+ "files": results,
334
+ "replacements": total,
335
+ "unfilled": unfilled,
336
+ "summary": summary,
337
+ "artifacts": [r["output"] for r in results],
338
+ }
339
+
340
+
341
+ def picture_names(prs):
342
+ names = []
343
+ for slide in prs.slides:
344
+ for shape in iter_shapes(slide.shapes):
345
+ if is_picture(shape) and shape.name and shape.name not in names:
346
+ names.append(shape.name)
347
+ return names
348
+
349
+
350
+ def action_replace_image(payload):
351
+ files = collect_pptx(payload.get("input"))
352
+ out = resolve_out(payload, payload.get("input"))
353
+ shape_name = (payload.get("shape") or "").strip()
354
+ image_arg = (payload.get("image") or "").strip()
355
+ if not shape_name:
356
+ raise ToolError(
357
+ "缺少 --shape(要替换的图片形状名)",
358
+ "例:replace-image --input ./模板 --shape Logo --image ./logo.png\n"
359
+ "形状名要和 PowerPoint 选择窗格(或「开始 → 选择 → 选择窗格」)里显示的名字完全一致。",
360
+ )
361
+ if not image_arg:
362
+ raise ToolError(
363
+ "缺少 --image(新图片文件)",
364
+ "例:replace-image --input ./模板 --shape Logo --image ./logo.png",
365
+ )
366
+ image = Path(image_arg).expanduser().resolve()
367
+ if not image.exists() or not image.is_file():
368
+ raise ToolError(
369
+ "新图片不存在:%s" % image_arg,
370
+ "确认 --image 指向一个 png / jpg / gif / bmp / tiff 图片文件。",
371
+ )
372
+ out.mkdir(parents=True, exist_ok=True)
373
+ results, total = [], 0
374
+ non_picture_hits, all_picture_names = [], []
375
+ for src in files:
376
+ prs = Presentation(str(src))
377
+ replaced, non_picture = 0, []
378
+ for slide in prs.slides:
379
+ rId = None
380
+ for shape in iter_shapes(slide.shapes):
381
+ if shape.name != shape_name:
382
+ continue
383
+ if not is_picture(shape):
384
+ non_picture.append(shape_kind(shape))
385
+ continue
386
+ if rId is None:
387
+ try:
388
+ _part, rId = slide.part.get_or_add_image_part(str(image))
389
+ except Exception:
390
+ raise ToolError(
391
+ "新图片无法读取:%s" % image.name,
392
+ "支持 png / jpg / gif / bmp / tiff;确认文件没损坏、后缀与内容一致。",
393
+ )
394
+ # 只换图片内容,保留原形状的位置与尺寸(版面不动)
395
+ shape._element.blipFill.blip.rEmbed = rId
396
+ replaced += 1
397
+ for kind in non_picture:
398
+ item = "%s(%s)" % (src.name, kind)
399
+ if item not in non_picture_hits:
400
+ non_picture_hits.append(item)
401
+ names = picture_names(prs)
402
+ for name in names:
403
+ if name not in all_picture_names:
404
+ all_picture_names.append(name)
405
+
406
+ target = unique_path(out, src.name)
407
+ if replaced == 0:
408
+ # 这一份没有该形状:原样带过去,不清空产物目录,也不假装替换过
409
+ shutil.copyfile(str(src), str(target))
410
+ results.append({
411
+ "input": str(src),
412
+ "input_name": src.name,
413
+ "output": str(target),
414
+ "output_name": target.name,
415
+ "replaced": 0,
416
+ "image": image.name,
417
+ "skipped": "没有名为 %s 的图片形状" % shape_name,
418
+ })
419
+ continue
420
+ prs.save(str(target))
421
+ results.append({
422
+ "input": str(src),
423
+ "input_name": src.name,
424
+ "output": str(target),
425
+ "output_name": target.name,
426
+ "replaced": replaced,
427
+ "image": image.name,
428
+ })
429
+ total += replaced
430
+
431
+ if total == 0:
432
+ if non_picture_hits:
433
+ raise ToolError(
434
+ "形状「%s」不是图片(%s)" % (shape_name, " / ".join(non_picture_hits[:6])),
435
+ "replace-image 只能替换图片形状;文字替换请用 fill。",
436
+ )
437
+ raise ToolError(
438
+ "没有名为「%s」的图片形状" % shape_name,
439
+ ("这些文件里的图片形状名:" + " / ".join(all_picture_names[:12])) if all_picture_names
440
+ else "这些文件里没有任何图片形状。",
441
+ )
442
+
443
+ skipped = sum(1 for r in results if r["replaced"] == 0)
444
+ summary = "处理 %d 个文件,替换图片形状 %d 处(形状名:%s)" % (len(files), total, shape_name)
445
+ if skipped:
446
+ summary += ";%d 个文件没有该形状,已原样带过" % skipped
447
+ return {
448
+ "action": "replace-image",
449
+ "input": str(Path(payload["input"]).expanduser().resolve()),
450
+ "out": str(out),
451
+ "files": results,
452
+ "replaced": total,
453
+ "skipped": skipped,
454
+ "image": str(image),
455
+ "summary": summary,
456
+ "artifacts": [r["output"] for r in results],
457
+ }
458
+
459
+
460
+ def slide_block(slide, index):
461
+ lines = []
462
+ title, title_id = "", None
463
+ try:
464
+ title_shape = slide.shapes.title
465
+ if title_shape is not None and getattr(title_shape, "has_text_frame", False):
466
+ title = title_shape.text_frame.text.strip()
467
+ title_id = get_shape_id(title_shape)
468
+ except Exception:
469
+ title, title_id = "", None
470
+ head = "=== 第 %d 页" % index
471
+ if title:
472
+ head += " · " + " ".join(title.split())
473
+ lines.append(head + " ===")
474
+ for shape in iter_shapes(slide.shapes):
475
+ if getattr(shape, "has_table", False):
476
+ rows = []
477
+ for row in shape.table.rows:
478
+ cells = [" ".join(c.text_frame.text.split()) for c in row.cells]
479
+ if any(cells):
480
+ rows.append(" | ".join(cells))
481
+ if rows:
482
+ lines.append("[表格 · %s]" % shape.name)
483
+ lines.extend(rows)
484
+ continue
485
+ if not getattr(shape, "has_text_frame", False):
486
+ continue
487
+ if title_id is not None and get_shape_id(shape) == title_id:
488
+ continue # 标题已经在页头里
489
+ text = shape.text_frame.text.strip()
490
+ if not text:
491
+ continue
492
+ lines.append("[正文 · %s]" % shape.name)
493
+ lines.extend(text.split("\n"))
494
+ try:
495
+ if slide.has_notes_slide:
496
+ notes = slide.notes_slide.notes_text_frame.text.strip()
497
+ if notes:
498
+ lines.append("[备注]")
499
+ lines.extend(notes.split("\n"))
500
+ except Exception:
501
+ pass # 备注页异常不影响正文提取
502
+ return "\n".join(lines)
503
+
504
+
505
+ def get_shape_id(shape):
506
+ try:
507
+ return shape.shape_id
508
+ except Exception:
509
+ return None
510
+
511
+
512
+ def action_extract_text(payload):
513
+ files = collect_pptx(payload.get("input"))
514
+ out = resolve_out(payload, payload.get("input"))
515
+ out.mkdir(parents=True, exist_ok=True)
516
+ results = []
517
+ for src in files:
518
+ prs = Presentation(str(src))
519
+ blocks = [slide_block(slide, i) for i, slide in enumerate(prs.slides, 1)]
520
+ text = "\n\n".join(blocks).strip() + "\n"
521
+ target = unique_path(out, src.stem + ".txt")
522
+ target.write_text(text, encoding="utf-8")
523
+ results.append({
524
+ "input": str(src),
525
+ "input_name": src.name,
526
+ "output": str(target),
527
+ "output_name": target.name,
528
+ "slides": len(prs.slides),
529
+ "chars": len(text),
530
+ })
531
+ return {
532
+ "action": "extract-text",
533
+ "input": str(Path(payload["input"]).expanduser().resolve()),
534
+ "out": str(out),
535
+ "files": results,
536
+ "summary": "提取 %d 个文件、共 %d 页文本" % (len(files), sum(r["slides"] for r in results)),
537
+ "artifacts": [r["output"] for r in results],
538
+ }
539
+
540
+
541
+ def action_info(payload):
542
+ files = collect_pptx(payload.get("input"))
543
+ results = []
544
+ for src in files:
545
+ prs = Presentation(str(src))
546
+ shapes = sum(len(slide.shapes) for slide in prs.slides)
547
+ pictures = 0
548
+ for slide in prs.slides:
549
+ pictures += sum(1 for shape in iter_shapes(slide.shapes) if is_picture(shape))
550
+ results.append({
551
+ "input": str(src),
552
+ "input_name": src.name,
553
+ "pages": len(prs.slides),
554
+ "shapes": shapes,
555
+ "pictures": pictures,
556
+ })
557
+ return {
558
+ "action": "info",
559
+ "input": str(Path(payload["input"]).expanduser().resolve()),
560
+ "files": results,
561
+ "pages": sum(r["pages"] for r in results),
562
+ "summary": "共 %d 个文件、%d 页" % (len(files), sum(r["pages"] for r in results)),
563
+ "artifacts": [],
564
+ }
565
+
566
+
567
+ # ── demo ─────────────────────────────────────────────────────────
568
+
569
+ def write_solid_png(path, rgb, size=16):
570
+ """手写一张纯色 PNG(不依赖 Pillow),给演示用的示例图片。"""
571
+ width = height = size
572
+ raw = b"".join(b"\x00" + bytes(rgb) * width for _ in range(height))
573
+
574
+ def chunk(tag, data):
575
+ return (struct.pack(">I", len(data)) + tag + data
576
+ + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
577
+
578
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
579
+ png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
580
+ + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
581
+ Path(path).write_bytes(png)
582
+
583
+
584
+ def make_demo_deck(path, logo_path, client, amount, rows=None):
585
+ """造一份带 {} 占位符 + 表格 + 图片形状的演示模板。"""
586
+ prs = Presentation()
587
+ slide = prs.slides.add_slide(prs.slide_layouts[5]) # Title Only
588
+ slide.shapes.title.text = "{{title}}"
589
+
590
+ box = slide.shapes.add_textbox(Inches(0.8), Inches(1.8), Inches(6.2), Inches(1.6))
591
+ frame = box.text_frame
592
+ frame.word_wrap = True
593
+ frame.paragraphs[0].text = "客户:{{client}}"
594
+ frame.add_paragraph().text = "金额:{{amount}}"
595
+
596
+ table_rows = rows or [["项目", "内容"], ["{{client}}", "{{amount}}"]]
597
+ table_shape = slide.shapes.add_table(
598
+ len(table_rows), len(table_rows[0]), Inches(0.8), Inches(3.6), Inches(6.2), Inches(1.4)
599
+ )
600
+ for i, row in enumerate(table_rows):
601
+ for j, value in enumerate(row):
602
+ table_shape.table.cell(i, j).text = value
603
+
604
+ picture = slide.shapes.add_picture(str(logo_path), Inches(7.2), Inches(0.8),
605
+ Inches(2.2), Inches(2.2))
606
+ picture.name = "Logo"
607
+ prs.save(str(path))
608
+
609
+
610
+ def action_demo(payload):
611
+ root = Path(tempfile.mkdtemp(prefix="dsh-pptx-batch-demo-"))
612
+ inputs = root / "样例"
613
+ inputs.mkdir(parents=True, exist_ok=True)
614
+ old_logo = root / "logo-旧.png"
615
+ new_logo = root / "logo-新.png"
616
+ write_solid_png(old_logo, (200, 30, 30))
617
+ write_solid_png(new_logo, (20, 120, 220))
618
+
619
+ make_demo_deck(inputs / "简报A.pptx", old_logo, "{{client}}", "{{amount}}")
620
+ make_demo_deck(inputs / "简报B.pptx", old_logo, "{{client}}", "{{amount}}",
621
+ rows=[["项目", "金额"], ["{{client}}", "{{amount}}"]])
622
+ out = root / "输出"
623
+ steps, artifacts = [], []
624
+
625
+ def record(name, result):
626
+ steps.append({
627
+ "step": name,
628
+ "action": result.get("action"),
629
+ "summary": result.get("summary", ""),
630
+ "out": result.get("out"),
631
+ })
632
+ artifacts.extend(result.get("artifacts", []))
633
+
634
+ filled_dir = out / "fill"
635
+ record("填占位符", action_fill({
636
+ "input": str(inputs), "out": str(filled_dir),
637
+ "set": ["title=" + DEMO_TITLE, "client=" + DEMO_CLIENT, "amount=" + DEMO_AMOUNT],
638
+ }))
639
+ record("换图片", action_replace_image({
640
+ "input": str(filled_dir), "out": str(out / "replace-image"),
641
+ "shape": "Logo", "image": str(new_logo),
642
+ }))
643
+ record("提文本", action_extract_text({
644
+ "input": str(out / "replace-image"), "out": str(out / "extract-text"),
645
+ }))
646
+ record("看结构", action_info({"input": str(inputs)}))
647
+
648
+ return {
649
+ "action": "demo",
650
+ "demoRoot": str(root),
651
+ "inputRoot": str(inputs),
652
+ "steps": steps,
653
+ "summary": "演示完成:%d 个动作全部跑通,产物 %d 个" % (len(steps), len(artifacts)),
654
+ "artifacts": artifacts,
655
+ }
656
+
657
+
658
+ ACTIONS = {
659
+ "fill": action_fill,
660
+ "replace-image": action_replace_image,
661
+ "extract-text": action_extract_text,
662
+ "info": action_info,
663
+ "demo": action_demo,
664
+ }
665
+
666
+
667
+ def main(argv):
668
+ if len(argv) < 3:
669
+ emit({"ok": False, "code": 2,
670
+ "message": "内部调用参数不足:需要 <action> 和 JSON payload",
671
+ "hint": "请通过 bin/cli.mjs 调用本脚本。"})
672
+ return 2
673
+ action, raw = argv[1], argv[2]
674
+ try:
675
+ payload = json.loads(raw) if raw else {}
676
+ except json.JSONDecodeError as exc:
677
+ emit({"ok": False, "code": 2, "message": "参数 JSON 解析失败:%s" % exc, "hint": ""})
678
+ return 2
679
+ fn = ACTIONS.get(action)
680
+ if not fn:
681
+ emit({"ok": False, "code": 2, "message": "未知动作:%s" % action,
682
+ "hint": "可用动作:%s" % " / ".join(k for k in ACTIONS if k != "demo")})
683
+ return 2
684
+ try:
685
+ result = fn(payload)
686
+ result.setdefault("ok", True)
687
+ emit(result)
688
+ return 0
689
+ except ToolError as exc:
690
+ emit({"ok": False, "code": exc.code, "message": exc.message, "hint": exc.hint})
691
+ return exc.code
692
+ except PermissionError as exc:
693
+ emit({"ok": False, "code": 1,
694
+ "message": "没有权限读写文件:%s" % exc,
695
+ "hint": "检查文件是否被 PowerPoint 占用、或输出目录是否有写权限。"})
696
+ return 1
697
+ except Exception as exc: # noqa: BLE001 —— 兜底,保证永远输出 JSON
698
+ traceback.print_exc(file=sys.stderr)
699
+ emit({"ok": False, "code": 1, "message": "处理失败:%s" % exc,
700
+ "hint": "文件可能已损坏或被加密;可先用 PowerPoint 打开另存一次再试。"})
701
+ return 1
702
+
703
+
704
+ if __name__ == "__main__":
705
+ sys.exit(main(sys.argv))
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "@deepwhale-cn/dsh-pptx-batch",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "PPT 批量填模板 / 替换文字与图片 / 导出内容清单(python-pptx)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "skill", "tool", "pptx-batch"],
8
+ "files": ["lib", "bin", "skill", "cordis.patch.yml", "tests/fixtures", "README.md", "LICENSE"],
9
+ "main": "./lib/index.js",
10
+ "exports": { ".": "./lib/index.js", "./package.json": "./package.json" },
11
+ "bin": { "dsh-pptx-batch": "./bin/cli.mjs" },
12
+ "engines": { "dsh": ">=0.1.0-rc.6 <0.3.0", "node": ">=18" },
13
+ "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
14
+ "dependencies": { "@deepwhale-cn/dsh-guard-core": "^0.5.1" },
15
+ "peerDependencies": { "@deepseek-ai/dsh-skill": "*" },
16
+ "repository": { "type": "git", "url": "git+https://github.com/feely0208/dsh-pptx-batch.git" },
17
+ "homepage": "https://www.deepwhale.org.cn/skills.html",
18
+ "bugs": { "url": "https://github.com/feely0208/dsh-pptx-batch/issues" }
19
+ }
@@ -0,0 +1,68 @@
1
+ # PPT 批量处理
2
+
3
+ **一句话**:整目录的 PPT 一次处理完 —— 批量填模板占位符、按形状名换图片、提取每页文本、看页数/形状数/图片数。只读输入,产物单独输出,不覆盖原文件。
4
+
5
+ ## 什么时候用
6
+
7
+ 用户说这些话时用:
8
+
9
+ - 「这套模板有 30 份按客户发,帮我把 `{{客户}}`、`{{金额}}` 填上」
10
+ - 「每份 PPT 右上角那个 Logo 都要换成新版,形状名是 Logo」
11
+ - 「把这几份 pptx 每页的文字导成 txt,我要核对内容」
12
+ - 「看看这批 PPT 各有多少页、多少张图」
13
+ - 用户给了一个装了很多 `.pptx` 的目录,要求批量处理其中文字或图片
14
+
15
+ ## 怎么调用
16
+
17
+ ```bash
18
+ <cli 路径> --help
19
+ ```
20
+
21
+ ```bash
22
+ # 批量填占位符(--set key=value 可重复;替换幻灯片里的 {{key}})
23
+ <cli 路径> fill --input <文件或目录> [--out <输出目录>] \
24
+ --set 客户=客户甲 [--set 金额=128000] [--json]
25
+
26
+ # 按形状名换图片(形状名精确匹配,大小写敏感)
27
+ <cli 路径> replace-image --input <文件或目录> [--out <输出目录>] \
28
+ --shape Logo --image <新图片文件> [--json]
29
+
30
+ # 提取每页文本到同名 .txt(标题 + 正文 + 表格 + 备注)
31
+ <cli 路径> extract-text --input <文件或目录> [--out <输出目录>] [--json]
32
+
33
+ # 页数 / 形状数 / 图片数
34
+ <cli 路径> info --input <文件或目录> [--json]
35
+
36
+ # 自造样例跑完整流程(不碰用户文件),先跑这个确认环境正常
37
+ <cli 路径> --demo
38
+ ```
39
+
40
+ ## 输出约定(要向用户说清)
41
+
42
+ - **不修改输入文件**;输出默认写到 `<输入目录>/_输出`,可用 `--out` 指定。
43
+ - 同名产物不覆盖:自动变成 `简报_1.pptx`、`简报_2.pptx`。
44
+ - 目录递归处理,跳过 `~$` 临时文件和 `_输出` 目录。
45
+ - 成功退出码 0;参数/输入错误退出码 2;缺依赖退出码 3;运行失败退出码 1。
46
+ - `--json` 输出结构化结果(每个文件的产物路径、替换次数、页数/形状数/图片数等)。
47
+ - `fill` 会把「填完之后还剩哪些 `{{...}}`」报在 `unfilled` 里 —— 用来抓模板里拼错的 key。
48
+ - `replace-image` 批量时,**没有该形状的文件原样带到输出目录**(JSON 里 `replaced: 0` + `skipped`),
49
+ 只有当整批都没有这个形状时才报错非 0 退出,避免打错一个字就把整批弄失败。
50
+
51
+ ## 边界(必须向用户说清)
52
+
53
+ - 只支持 `.pptx`;`.ppt` 老格式、`.pptm`、加密文档不支持,需先另存为 `.pptx`。
54
+ - `fill` 覆盖**文本框、占位符、表格单元格、组合形状里的文本框**;
55
+ **不处理**图表文字、SmartArt 文字、图片里的文字、批注、母版/版式上的文字,也不跨段落匹配。
56
+ - `fill` 的匹配是**字面匹配**(`{{key}}`,key 大小写敏感,允许 `{{ key }}` 这种空格),不支持正则;
57
+ 同一个 key 一次只能给一个值。
58
+ - **格式尽量保留但不保证 100%**:占位符整段在一个 run 里时,该 run 的字体/字号/颜色完全不动;
59
+ 跨多个 run 的匹配会把这几段文字并到起始 run 的格式上,中间 run 的局部格式会丢。
60
+ 重要模板先跑一份对比,再批量用。
61
+ - `replace-image` 只换**图片内容**:形状的位置、尺寸、名称都不变。新图比例和原图不同时会被**拉伸**,
62
+ 裁剪(crop)设置也沿用原来的;不支持按图片内容或页码挑选,只按形状名,同名形状会全部替换。
63
+ - `extract-text` 提取标题、正文、表格、备注页文字;不提取图表数据、SmartArt、页码和母版文字,
64
+ 图片只留位置不留文字(没有 OCR)。
65
+ - `info` 的「形状数」是每页顶层形状数之和(组合形状算 1 个,其内部形状不计入),
66
+ 「图片数」包含组合形状里的图片。
67
+ - 缺 `python3` 或 `python-pptx` 时会明确打印安装命令(`python3 -m pip install python-pptx`),不会静默失败。
68
+ - 不联网、不上传文件,全部在本机由 python-pptx 处理。