@deepwhale-cn/dsh-doc-batch 0.0.0-stage → 0.1.4
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 +51 -2
- package/assets/deepwhale-badge.png +0 -0
- package/bin/cli-docx.mjs +245 -0
- package/bin/cli-pdf.mjs +581 -0
- package/bin/cli-xlsx.mjs +225 -0
- package/bin/cli.mjs +63 -0
- package/cordis.patch.yml +4 -0
- package/lib/api.js +162 -0
- package/lib/client.js +235 -0
- package/lib/docx_tool.py +651 -0
- package/lib/index.js +26 -0
- package/lib/pdf_ops.py +431 -0
- package/lib/xlsx_tool.py +554 -0
- package/package.json +65 -4
- package/skill/doc-batch.md +202 -0
package/lib/docx_tool.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
dsh-docx-batch 的真实处理逻辑(python-docx)
|
|
5
|
+
|
|
6
|
+
由 bin/cli.mjs 通过 spawnSync 调用:
|
|
7
|
+
python3 lib/docx_tool.py <action> '<json payload>'
|
|
8
|
+
约定:
|
|
9
|
+
- stdout 只输出一行 JSON(最后一行),人类可读文案由 Node 侧负责
|
|
10
|
+
- stderr 输出异常栈(调试用)
|
|
11
|
+
- 退出码:0 成功 / 1 运行失败 / 2 参数或输入问题 / 3 缺依赖
|
|
12
|
+
- 只读输入文件,所有产物写进 --out 目录(默认 <输入目录>/_输出)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import csv
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import traceback
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from docx import Document
|
|
26
|
+
from docx.oxml.ns import qn
|
|
27
|
+
from docx.table import Table
|
|
28
|
+
from docx.text.paragraph import Paragraph
|
|
29
|
+
except ImportError as exc: # 缺依赖必须明确提示安装命令,不能静默
|
|
30
|
+
print(json.dumps({
|
|
31
|
+
"ok": False,
|
|
32
|
+
"code": 3,
|
|
33
|
+
"message": "缺少 Python 依赖:%s" % getattr(exc, "name", exc),
|
|
34
|
+
"hint": "请先安装:\n python3 -m pip install python-docx\n"
|
|
35
|
+
"如果 python3 也没有:\n macOS: brew install python3\n Ubuntu: sudo apt install python3",
|
|
36
|
+
}, ensure_ascii=False))
|
|
37
|
+
sys.exit(3)
|
|
38
|
+
|
|
39
|
+
DOCX_EXT = ".docx"
|
|
40
|
+
SKIP_DIRS = {"_输出", "node_modules", ".git", "__pycache__", ".venv", "venv"}
|
|
41
|
+
DEMO_HEADER = "机密 · 深鲸科技"
|
|
42
|
+
DEMO_FOOTER = "第 1 页"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── 错误 ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
class ToolError(Exception):
|
|
48
|
+
def __init__(self, message, hint="", code=2):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.message = message
|
|
51
|
+
self.hint = hint
|
|
52
|
+
self.code = code
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def emit(obj):
|
|
56
|
+
print(json.dumps(obj, ensure_ascii=False))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── 路径与文件收集 ────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def natural_key(text):
|
|
62
|
+
return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", text)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_out(payload, input_path):
|
|
66
|
+
"""输出目录:--out 优先,否则 <输入目录>/_输出。"""
|
|
67
|
+
out = payload.get("out")
|
|
68
|
+
if out:
|
|
69
|
+
return Path(out).expanduser().resolve()
|
|
70
|
+
base = Path(input_path).expanduser().resolve()
|
|
71
|
+
if not base.is_dir():
|
|
72
|
+
base = base.parent
|
|
73
|
+
return base / "_输出"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _out_skip_set(out_dir):
|
|
77
|
+
try:
|
|
78
|
+
return {str(out_dir.resolve())}
|
|
79
|
+
except OSError:
|
|
80
|
+
return set()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def collect_docx(input_path, out_dir=None):
|
|
84
|
+
"""接受单个 .docx 或目录(递归),按文件名自然排序。"""
|
|
85
|
+
if not input_path:
|
|
86
|
+
raise ToolError("缺少 --input", "例:replace --input ./合同目录 --from 旧 --to 新")
|
|
87
|
+
root = Path(input_path).expanduser().resolve()
|
|
88
|
+
if not root.exists():
|
|
89
|
+
raise ToolError(
|
|
90
|
+
"输入不存在:%s" % input_path,
|
|
91
|
+
"请检查路径是否正确;传目录会递归处理其中的 .docx 文件。",
|
|
92
|
+
)
|
|
93
|
+
skip_out = _out_skip_set(out_dir) if out_dir else set()
|
|
94
|
+
if root.is_file():
|
|
95
|
+
if root.suffix.lower() != DOCX_EXT:
|
|
96
|
+
raise ToolError(
|
|
97
|
+
"输入不是 .docx 文件:%s" % root.name,
|
|
98
|
+
"本插件只处理 Word 2007+ 的 .docx;.doc 请先用 Word/WPS 另存为 .docx。",
|
|
99
|
+
)
|
|
100
|
+
return [root]
|
|
101
|
+
files = []
|
|
102
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
103
|
+
dirnames[:] = sorted(
|
|
104
|
+
[d for d in dirnames if d not in SKIP_DIRS and not d.startswith("~$")],
|
|
105
|
+
key=natural_key,
|
|
106
|
+
)
|
|
107
|
+
if str(Path(dirpath).resolve()) in skip_out:
|
|
108
|
+
dirnames[:] = []
|
|
109
|
+
continue
|
|
110
|
+
for name in filenames:
|
|
111
|
+
if name.startswith("~$"):
|
|
112
|
+
continue # Word 打开时的临时文件
|
|
113
|
+
p = Path(dirpath) / name
|
|
114
|
+
if p.suffix.lower() == DOCX_EXT:
|
|
115
|
+
files.append(p)
|
|
116
|
+
files.sort(key=lambda p: natural_key(str(p.relative_to(root))))
|
|
117
|
+
if not files:
|
|
118
|
+
raise ToolError(
|
|
119
|
+
"目录里没有 .docx 文件:%s" % root,
|
|
120
|
+
"确认目录选对了;本插件不处理 .doc(请先另存为 .docx)。",
|
|
121
|
+
)
|
|
122
|
+
return files
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def unique_path(directory, filename):
|
|
126
|
+
"""同名文件不覆盖,追加 _1/_2 序号。"""
|
|
127
|
+
directory = Path(directory)
|
|
128
|
+
target = directory / filename
|
|
129
|
+
if not target.exists():
|
|
130
|
+
return target
|
|
131
|
+
stem, suffix = target.stem, target.suffix
|
|
132
|
+
n = 1
|
|
133
|
+
while (directory / ("%s_%d%s" % (stem, n, suffix))).exists():
|
|
134
|
+
n += 1
|
|
135
|
+
return directory / ("%s_%d%s" % (stem, n, suffix))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── docx 遍历 ─────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def iter_block_items(parent):
|
|
141
|
+
"""按文档顺序遍历段落和表格。"""
|
|
142
|
+
body = parent.element.body
|
|
143
|
+
for child in body.iterchildren():
|
|
144
|
+
if child.tag == qn("w:p"):
|
|
145
|
+
yield Paragraph(child, parent)
|
|
146
|
+
elif child.tag == qn("w:tbl"):
|
|
147
|
+
yield Table(child, parent)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def iter_cell_paragraphs(cell):
|
|
151
|
+
for p in cell.paragraphs:
|
|
152
|
+
yield p
|
|
153
|
+
for table in cell.tables:
|
|
154
|
+
for row in table.rows:
|
|
155
|
+
for c in row.cells:
|
|
156
|
+
yield from iter_cell_paragraphs(c)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def iter_table_paragraphs(table):
|
|
160
|
+
for row in table.rows:
|
|
161
|
+
for cell in row.cells:
|
|
162
|
+
yield from iter_cell_paragraphs(cell)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def all_editable_paragraphs(doc):
|
|
166
|
+
"""正文 + 表格 + 页眉页脚,去重(链接到前一节的页眉是同一个 part)。"""
|
|
167
|
+
out, seen = [], set()
|
|
168
|
+
|
|
169
|
+
def add(p):
|
|
170
|
+
key = id(p._p)
|
|
171
|
+
if key not in seen:
|
|
172
|
+
seen.add(key)
|
|
173
|
+
out.append(p)
|
|
174
|
+
|
|
175
|
+
for p in doc.paragraphs:
|
|
176
|
+
add(p)
|
|
177
|
+
for t in doc.tables:
|
|
178
|
+
for p in iter_table_paragraphs(t):
|
|
179
|
+
add(p)
|
|
180
|
+
for section in doc.sections:
|
|
181
|
+
for part in (section.header, section.footer):
|
|
182
|
+
try:
|
|
183
|
+
for p in part.paragraphs:
|
|
184
|
+
add(p)
|
|
185
|
+
for t in part.tables:
|
|
186
|
+
for p in iter_table_paragraphs(t):
|
|
187
|
+
add(p)
|
|
188
|
+
except Exception:
|
|
189
|
+
# 极少数残缺文档取不到页眉/页脚定义:跳过,不影响正文
|
|
190
|
+
continue
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── 文本替换 ──────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def replace_in_paragraph(paragraph, pairs):
|
|
197
|
+
"""先做 run 内替换(保留格式),跨 run 的再做段落级重建。返回替换次数。"""
|
|
198
|
+
count = 0
|
|
199
|
+
if not paragraph.runs:
|
|
200
|
+
return 0
|
|
201
|
+
for old, new in pairs:
|
|
202
|
+
if not old:
|
|
203
|
+
continue
|
|
204
|
+
for run in paragraph.runs:
|
|
205
|
+
if old in run.text:
|
|
206
|
+
count += run.text.count(old)
|
|
207
|
+
run.text = run.text.replace(old, new)
|
|
208
|
+
if old in paragraph.text:
|
|
209
|
+
runs = paragraph.runs
|
|
210
|
+
full = "".join(r.text for r in runs)
|
|
211
|
+
if old in full:
|
|
212
|
+
count += full.count(old)
|
|
213
|
+
runs[0].text = full.replace(old, new)
|
|
214
|
+
for r in runs[1:]:
|
|
215
|
+
r.text = ""
|
|
216
|
+
return count
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── 各动作 ────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def action_replace(payload):
|
|
222
|
+
files = collect_docx(payload.get("input"))
|
|
223
|
+
out = resolve_out(payload, payload.get("input"))
|
|
224
|
+
olds = payload.get("from") or []
|
|
225
|
+
news = payload.get("to") or []
|
|
226
|
+
if not olds:
|
|
227
|
+
raise ToolError("缺少 --from(要替换的旧文本)",
|
|
228
|
+
"例:replace --input ./合同 --from 旧公司名 --to 新公司名")
|
|
229
|
+
if len(olds) != len(news):
|
|
230
|
+
raise ToolError(
|
|
231
|
+
"--from 与 --to 数量不一致(%d vs %d)" % (len(olds), len(news)),
|
|
232
|
+
"每一组 --from 都要配一个 --to,按顺序一一对应。",
|
|
233
|
+
)
|
|
234
|
+
pairs = list(zip(olds, news))
|
|
235
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
results, total = [], 0
|
|
237
|
+
for src in files:
|
|
238
|
+
doc = Document(str(src))
|
|
239
|
+
n = 0
|
|
240
|
+
for p in all_editable_paragraphs(doc):
|
|
241
|
+
n += replace_in_paragraph(p, pairs)
|
|
242
|
+
target = unique_path(out, src.name)
|
|
243
|
+
doc.save(str(target))
|
|
244
|
+
results.append({
|
|
245
|
+
"input": str(src),
|
|
246
|
+
"input_name": src.name,
|
|
247
|
+
"output": str(target),
|
|
248
|
+
"output_name": target.name,
|
|
249
|
+
"replacements": n,
|
|
250
|
+
})
|
|
251
|
+
total += n
|
|
252
|
+
return {
|
|
253
|
+
"action": "replace",
|
|
254
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
255
|
+
"out": str(out),
|
|
256
|
+
"files": results,
|
|
257
|
+
"replacements": total,
|
|
258
|
+
"summary": "处理 %d 个文件,共替换 %d 处" % (len(files), total),
|
|
259
|
+
"artifacts": [r["output"] for r in results],
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def doc_body_text(doc):
|
|
264
|
+
lines = []
|
|
265
|
+
for block in iter_block_items(doc):
|
|
266
|
+
if isinstance(block, Paragraph):
|
|
267
|
+
lines.append(block.text)
|
|
268
|
+
else:
|
|
269
|
+
lines.append("")
|
|
270
|
+
lines.append("[表格]")
|
|
271
|
+
for row in block.rows:
|
|
272
|
+
lines.append("\t".join(c.text.replace("\n", " ") for c in row.cells))
|
|
273
|
+
return "\n".join(lines).strip() + "\n"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def action_extract(payload):
|
|
277
|
+
files = collect_docx(payload.get("input"))
|
|
278
|
+
out = resolve_out(payload, payload.get("input"))
|
|
279
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
results = []
|
|
281
|
+
for src in files:
|
|
282
|
+
text = doc_body_text(Document(str(src)))
|
|
283
|
+
target = unique_path(out, src.stem + ".txt")
|
|
284
|
+
target.write_text(text, encoding="utf-8")
|
|
285
|
+
results.append({
|
|
286
|
+
"input": str(src),
|
|
287
|
+
"input_name": src.name,
|
|
288
|
+
"output": str(target),
|
|
289
|
+
"output_name": target.name,
|
|
290
|
+
"chars": len(text),
|
|
291
|
+
})
|
|
292
|
+
return {
|
|
293
|
+
"action": "extract",
|
|
294
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
295
|
+
"out": str(out),
|
|
296
|
+
"files": results,
|
|
297
|
+
"summary": "提取 %d 个文件的正文文本" % len(files),
|
|
298
|
+
"artifacts": [r["output"] for r in results],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def copy_run(dst_paragraph, src_run):
|
|
303
|
+
run = dst_paragraph.add_run(src_run.text)
|
|
304
|
+
try:
|
|
305
|
+
run.bold = src_run.bold
|
|
306
|
+
run.italic = src_run.italic
|
|
307
|
+
run.underline = src_run.underline
|
|
308
|
+
if src_run.font.size is not None:
|
|
309
|
+
run.font.size = src_run.font.size
|
|
310
|
+
if src_run.font.name:
|
|
311
|
+
run.font.name = src_run.font.name
|
|
312
|
+
except Exception:
|
|
313
|
+
pass # 字体属性个别无法复制时不影响文本合并
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def copy_paragraph(dst_doc, src_paragraph):
|
|
317
|
+
p = dst_doc.add_paragraph()
|
|
318
|
+
try:
|
|
319
|
+
style_name = src_paragraph.style.name if src_paragraph.style else None
|
|
320
|
+
if style_name and style_name.lower() not in ("normal", "正文"):
|
|
321
|
+
p.style = style_name
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
try:
|
|
325
|
+
if src_paragraph.alignment is not None:
|
|
326
|
+
p.alignment = src_paragraph.alignment
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
for run in src_paragraph.runs:
|
|
330
|
+
copy_run(p, run)
|
|
331
|
+
return p
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def copy_table(dst_doc, src_table):
|
|
335
|
+
rows = len(src_table.rows)
|
|
336
|
+
cols = max((len(r.cells) for r in src_table.rows), default=0)
|
|
337
|
+
if rows == 0 or cols == 0:
|
|
338
|
+
return None
|
|
339
|
+
table = dst_doc.add_table(rows=rows, cols=cols)
|
|
340
|
+
for i, row in enumerate(src_table.rows):
|
|
341
|
+
for j, cell in enumerate(row.cells):
|
|
342
|
+
if j < cols:
|
|
343
|
+
table.cell(i, j).text = cell.text
|
|
344
|
+
try:
|
|
345
|
+
table.style = src_table.style.name
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
return table
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def action_merge(payload):
|
|
352
|
+
files = collect_docx(payload.get("input"))
|
|
353
|
+
out = resolve_out(payload, payload.get("input"))
|
|
354
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
merged = Document()
|
|
356
|
+
results = []
|
|
357
|
+
for src in files:
|
|
358
|
+
doc = Document(str(src))
|
|
359
|
+
paragraphs_before = len(merged.paragraphs)
|
|
360
|
+
for block in iter_block_items(doc):
|
|
361
|
+
if isinstance(block, Paragraph):
|
|
362
|
+
copy_paragraph(merged, block)
|
|
363
|
+
else:
|
|
364
|
+
copy_table(merged, block)
|
|
365
|
+
results.append({
|
|
366
|
+
"input": str(src),
|
|
367
|
+
"input_name": src.name,
|
|
368
|
+
"paragraphs": len(merged.paragraphs) - paragraphs_before,
|
|
369
|
+
"source_paragraphs": len(doc.paragraphs),
|
|
370
|
+
})
|
|
371
|
+
target = unique_path(out, "merged.docx")
|
|
372
|
+
merged.save(str(target))
|
|
373
|
+
return {
|
|
374
|
+
"action": "merge",
|
|
375
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
376
|
+
"out": str(out),
|
|
377
|
+
"files": results,
|
|
378
|
+
"output": str(target),
|
|
379
|
+
"output_name": target.name,
|
|
380
|
+
"paragraphs": len(merged.paragraphs),
|
|
381
|
+
"summary": "按文件名顺序合并 %d 个文件,共 %d 段" % (len(files), len(merged.paragraphs)),
|
|
382
|
+
"artifacts": [str(target)],
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def section_texts(doc):
|
|
387
|
+
sections = []
|
|
388
|
+
for idx, section in enumerate(doc.sections, 1):
|
|
389
|
+
item = {"section": idx, "header": [], "footer": []}
|
|
390
|
+
for label, part in (("header", section.header), ("footer", section.footer)):
|
|
391
|
+
try:
|
|
392
|
+
texts = [p.text.strip() for p in part.paragraphs if p.text.strip()]
|
|
393
|
+
except Exception:
|
|
394
|
+
texts = []
|
|
395
|
+
item[label] = texts
|
|
396
|
+
sections.append(item)
|
|
397
|
+
return sections
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def action_headers(payload):
|
|
401
|
+
files = collect_docx(payload.get("input"))
|
|
402
|
+
out = resolve_out(payload, payload.get("input"))
|
|
403
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
results, lines = [], []
|
|
405
|
+
for src in files:
|
|
406
|
+
sections = section_texts(Document(str(src)))
|
|
407
|
+
hit = sum(1 for s in sections if s["header"] or s["footer"])
|
|
408
|
+
results.append({
|
|
409
|
+
"input": str(src),
|
|
410
|
+
"input_name": src.name,
|
|
411
|
+
"sections": len(sections),
|
|
412
|
+
"sections_with_text": hit,
|
|
413
|
+
"detail": sections,
|
|
414
|
+
})
|
|
415
|
+
lines.append("文件:%s(%d 节)" % (src.name, len(sections)))
|
|
416
|
+
for s in sections:
|
|
417
|
+
h = " / ".join(s["header"]) if s["header"] else "(空)"
|
|
418
|
+
f = " / ".join(s["footer"]) if s["footer"] else "(空)"
|
|
419
|
+
lines.append(" 第 %d 节 页眉:%s" % (s["section"], h))
|
|
420
|
+
lines.append(" 页脚:%s" % f)
|
|
421
|
+
lines.append("")
|
|
422
|
+
target = unique_path(out, "headers.txt")
|
|
423
|
+
target.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
|
|
424
|
+
return {
|
|
425
|
+
"action": "headers",
|
|
426
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
427
|
+
"out": str(out),
|
|
428
|
+
"files": results,
|
|
429
|
+
"output": str(target),
|
|
430
|
+
"output_name": target.name,
|
|
431
|
+
"summary": "列出 %d 个文件的页眉页脚" % len(files),
|
|
432
|
+
"artifacts": [str(target)],
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def inline_runs(paragraph):
|
|
437
|
+
parts = []
|
|
438
|
+
for run in paragraph.runs:
|
|
439
|
+
text = run.text
|
|
440
|
+
if not text:
|
|
441
|
+
continue
|
|
442
|
+
if run.bold and run.italic:
|
|
443
|
+
text = "***%s***" % text
|
|
444
|
+
elif run.bold:
|
|
445
|
+
text = "**%s**" % text
|
|
446
|
+
elif run.italic:
|
|
447
|
+
text = "*%s*" % text
|
|
448
|
+
parts.append(text)
|
|
449
|
+
text = "".join(parts).strip()
|
|
450
|
+
return text or paragraph.text.strip()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def table_to_md(table):
|
|
454
|
+
rows = []
|
|
455
|
+
for row in table.rows:
|
|
456
|
+
cells = [" ".join(c.text.split()) for c in row.cells]
|
|
457
|
+
if any(cells):
|
|
458
|
+
rows.append(cells)
|
|
459
|
+
if not rows:
|
|
460
|
+
return ""
|
|
461
|
+
width = max(len(r) for r in rows)
|
|
462
|
+
rows = [r + [""] * (width - len(r)) for r in rows]
|
|
463
|
+
|
|
464
|
+
def esc(s):
|
|
465
|
+
return s.replace("|", "\\|")
|
|
466
|
+
|
|
467
|
+
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |",
|
|
468
|
+
"| " + " | ".join(["---"] * width) + " |"]
|
|
469
|
+
for r in rows[1:]:
|
|
470
|
+
out.append("| " + " | ".join(esc(c) for c in r) + " |")
|
|
471
|
+
return "\n".join(out)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def paragraph_to_md(paragraph, state):
|
|
475
|
+
style = (paragraph.style.name if paragraph.style else "") or ""
|
|
476
|
+
style_low = style.lower()
|
|
477
|
+
text = inline_runs(paragraph)
|
|
478
|
+
m = re.match(r"^(heading|标题)\s*([1-6])$", style, re.I)
|
|
479
|
+
if m:
|
|
480
|
+
return "#" * int(m.group(2)) + " " + text
|
|
481
|
+
if style_low.startswith("title"):
|
|
482
|
+
return "# " + text
|
|
483
|
+
if "list bullet" in style_low or ("列表" in style and "项目" in style):
|
|
484
|
+
return "- " + text
|
|
485
|
+
if "list number" in style_low:
|
|
486
|
+
state["number"] += 1
|
|
487
|
+
return "%d. %s" % (state["number"], text)
|
|
488
|
+
if not text:
|
|
489
|
+
return ""
|
|
490
|
+
return text
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def doc_to_md(doc):
|
|
494
|
+
blocks = []
|
|
495
|
+
state = {"number": 0}
|
|
496
|
+
for block in iter_block_items(doc):
|
|
497
|
+
if isinstance(block, Paragraph):
|
|
498
|
+
md = paragraph_to_md(block, state)
|
|
499
|
+
else:
|
|
500
|
+
md = table_to_md(block)
|
|
501
|
+
if md.strip():
|
|
502
|
+
blocks.append(md)
|
|
503
|
+
return "\n\n".join(blocks).strip() + "\n"
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def action_to_md(payload):
|
|
507
|
+
files = collect_docx(payload.get("input"))
|
|
508
|
+
out = resolve_out(payload, payload.get("input"))
|
|
509
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
510
|
+
results = []
|
|
511
|
+
for src in files:
|
|
512
|
+
md = doc_to_md(Document(str(src)))
|
|
513
|
+
target = unique_path(out, src.stem + ".md")
|
|
514
|
+
target.write_text(md, encoding="utf-8")
|
|
515
|
+
results.append({
|
|
516
|
+
"input": str(src),
|
|
517
|
+
"input_name": src.name,
|
|
518
|
+
"output": str(target),
|
|
519
|
+
"output_name": target.name,
|
|
520
|
+
"chars": len(md),
|
|
521
|
+
})
|
|
522
|
+
return {
|
|
523
|
+
"action": "to-md",
|
|
524
|
+
"input": str(Path(payload["input"]).expanduser().resolve()),
|
|
525
|
+
"out": str(out),
|
|
526
|
+
"files": results,
|
|
527
|
+
"summary": "转换 %d 个文件为 Markdown" % len(files),
|
|
528
|
+
"artifacts": [r["output"] for r in results],
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ── demo ─────────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
def _make_demo_docx(path, heading, lines, table_rows=None, header=True):
|
|
535
|
+
doc = Document()
|
|
536
|
+
doc.add_heading(heading, level=1)
|
|
537
|
+
for line in lines:
|
|
538
|
+
doc.add_paragraph(line)
|
|
539
|
+
if table_rows:
|
|
540
|
+
table = doc.add_table(rows=len(table_rows), cols=len(table_rows[0]))
|
|
541
|
+
for i, row in enumerate(table_rows):
|
|
542
|
+
for j, value in enumerate(row):
|
|
543
|
+
table.cell(i, j).text = value
|
|
544
|
+
if header:
|
|
545
|
+
doc.sections[0].header.paragraphs[0].text = DEMO_HEADER
|
|
546
|
+
doc.sections[0].footer.paragraphs[0].text = DEMO_FOOTER
|
|
547
|
+
doc.save(str(path))
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def action_demo(payload):
|
|
551
|
+
root = Path(tempfile.mkdtemp(prefix="dsh-docx-batch-demo-"))
|
|
552
|
+
inputs = root / "样例"
|
|
553
|
+
inputs.mkdir(parents=True, exist_ok=True)
|
|
554
|
+
_make_demo_docx(
|
|
555
|
+
inputs / "合同A.docx",
|
|
556
|
+
"第一章 总则",
|
|
557
|
+
["甲方:深鲸科技", "乙方:示例客户", "本合同由深鲸科技起草。"],
|
|
558
|
+
[["条款", "内容"], ["保密", "深鲸科技对数据保密"]],
|
|
559
|
+
)
|
|
560
|
+
_make_demo_docx(
|
|
561
|
+
inputs / "附件B.docx",
|
|
562
|
+
"附件 B",
|
|
563
|
+
["附件内容第一段", "附件内容第二段"],
|
|
564
|
+
)
|
|
565
|
+
out = root / "输出"
|
|
566
|
+
steps, artifacts = [], []
|
|
567
|
+
|
|
568
|
+
def run(name, fn, extra):
|
|
569
|
+
p = {"input": str(inputs), "out": str(out / name)}
|
|
570
|
+
p.update(extra)
|
|
571
|
+
result = fn(p)
|
|
572
|
+
steps.append({
|
|
573
|
+
"step": name,
|
|
574
|
+
"action": result.get("action"),
|
|
575
|
+
"summary": result.get("summary", ""),
|
|
576
|
+
"out": result.get("out"),
|
|
577
|
+
})
|
|
578
|
+
artifacts.extend(result.get("artifacts", []))
|
|
579
|
+
|
|
580
|
+
run("替换", action_replace, {"from": ["深鲸科技"], "to": ["深海鲸"]})
|
|
581
|
+
replaced_dir = out / "替换"
|
|
582
|
+
# 提取/转 Markdown 用替换后的产物,顺带演示链路
|
|
583
|
+
p_extract = {"input": str(replaced_dir), "out": str(out / "文本")}
|
|
584
|
+
result = action_extract(p_extract)
|
|
585
|
+
steps.append({"step": "文本", "action": "extract", "summary": result["summary"], "out": result["out"]})
|
|
586
|
+
artifacts.extend(result["artifacts"])
|
|
587
|
+
p_md = {"input": str(replaced_dir), "out": str(out / "markdown")}
|
|
588
|
+
result = action_to_md(p_md)
|
|
589
|
+
steps.append({"step": "markdown", "action": "to-md", "summary": result["summary"], "out": result["out"]})
|
|
590
|
+
artifacts.extend(result["artifacts"])
|
|
591
|
+
run("合并", action_merge, {})
|
|
592
|
+
run("页眉页脚", action_headers, {})
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
"action": "demo",
|
|
596
|
+
"demoRoot": str(root),
|
|
597
|
+
"steps": steps,
|
|
598
|
+
"summary": "演示完成:5 个动作全部跑通,产物 %d 个" % len(artifacts),
|
|
599
|
+
"artifacts": artifacts,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
ACTIONS = {
|
|
604
|
+
"replace": action_replace,
|
|
605
|
+
"extract": action_extract,
|
|
606
|
+
"merge": action_merge,
|
|
607
|
+
"headers": action_headers,
|
|
608
|
+
"to-md": action_to_md,
|
|
609
|
+
"demo": action_demo,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def main(argv):
|
|
614
|
+
if len(argv) < 3:
|
|
615
|
+
emit({"ok": False, "code": 2,
|
|
616
|
+
"message": "内部调用参数不足:需要 <action> 和 JSON payload",
|
|
617
|
+
"hint": "请通过 bin/cli.mjs 调用本脚本。"})
|
|
618
|
+
return 2
|
|
619
|
+
action, raw = argv[1], argv[2]
|
|
620
|
+
try:
|
|
621
|
+
payload = json.loads(raw) if raw else {}
|
|
622
|
+
except json.JSONDecodeError as exc:
|
|
623
|
+
emit({"ok": False, "code": 2, "message": "参数 JSON 解析失败:%s" % exc, "hint": ""})
|
|
624
|
+
return 2
|
|
625
|
+
fn = ACTIONS.get(action)
|
|
626
|
+
if not fn:
|
|
627
|
+
emit({"ok": False, "code": 2, "message": "未知动作:%s" % action,
|
|
628
|
+
"hint": "可用动作:%s" % " / ".join(k for k in ACTIONS if k != "demo")})
|
|
629
|
+
return 2
|
|
630
|
+
try:
|
|
631
|
+
result = fn(payload)
|
|
632
|
+
result.setdefault("ok", True)
|
|
633
|
+
emit(result)
|
|
634
|
+
return 0
|
|
635
|
+
except ToolError as exc:
|
|
636
|
+
emit({"ok": False, "code": exc.code, "message": exc.message, "hint": exc.hint})
|
|
637
|
+
return exc.code
|
|
638
|
+
except PermissionError as exc:
|
|
639
|
+
emit({"ok": False, "code": 1,
|
|
640
|
+
"message": "没有权限读写文件:%s" % exc,
|
|
641
|
+
"hint": "检查目标文件是否被 Word 占用、或输出目录是否有写权限。"})
|
|
642
|
+
return 1
|
|
643
|
+
except Exception as exc: # noqa: BLE001 —— 兜底,保证永远输出 JSON
|
|
644
|
+
traceback.print_exc(file=sys.stderr)
|
|
645
|
+
emit({"ok": False, "code": 1, "message": "处理失败:%s" % exc,
|
|
646
|
+
"hint": "文件可能已损坏或被加密;可先用 Word 打开另存一次再试。"})
|
|
647
|
+
return 1
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
if __name__ == "__main__":
|
|
651
|
+
sys.exit(main(sys.argv))
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createPlugin, pkgRootFrom } from '@deepwhale-cn/dsh-guard-core/plugin';
|
|
2
|
+
import { registerDocBatchApi } from './api.js';
|
|
3
|
+
|
|
4
|
+
const base = createPlugin({
|
|
5
|
+
name: 'doc-batch',
|
|
6
|
+
pkgRoot: pkgRootFrom(import.meta.url),
|
|
7
|
+
skillFile: 'skill/doc-batch.md',
|
|
8
|
+
binFile: 'bin/cli.mjs',
|
|
9
|
+
description: '深鲸文档批量处理:Word / Excel / PDF 一次处理整目录(替换、提取、合并、拆分、压缩、水印、导出)',
|
|
10
|
+
whenToUse: '当用户要批量处理整目录的文档时使用:Word(替换/提取/合并/页眉页脚/转 Markdown)、Excel(合并/清洗/汇总/导出 CSV)、PDF(合并/拆分/压缩/水印/提取文本或图片)。典型说法:「把这批合同里的旧公司名全换掉」「这些表合并成一张」「把这些 PDF 按页拆开」。',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const name = base.name;
|
|
14
|
+
export const inject = [...base.inject, 'webServer'];
|
|
15
|
+
|
|
16
|
+
export function apply(ctx) {
|
|
17
|
+
base.apply(ctx);
|
|
18
|
+
try {
|
|
19
|
+
if (ctx.webServer && typeof ctx.webServer.register === 'function') {
|
|
20
|
+
registerDocBatchApi(ctx);
|
|
21
|
+
ctx.logger?.info?.('[doc-batch] 已挂面板出口 /dsh-doc-batch/api');
|
|
22
|
+
}
|
|
23
|
+
} catch (e) {
|
|
24
|
+
ctx.logger?.warn?.('[doc-batch] 面板出口注册失败(CLI/skill 不受影响): ' + (e?.message || e));
|
|
25
|
+
}
|
|
26
|
+
}
|