@deepwhale-cn/dsh-pptx-batch 0.0.0-stage → 0.1.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 +99 -2
- package/bin/cli.mjs +284 -0
- package/cordis.patch.yml +4 -0
- package/lib/api.js +27 -0
- package/lib/client.js +87 -0
- package/lib/core.mjs +52 -0
- package/lib/index.js +10 -0
- package/lib/pptx_tool.py +705 -0
- package/package.json +53 -4
- package/skill/pptx-batch.md +68 -0
package/lib/pptx_tool.py
ADDED
|
@@ -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))
|