@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.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 +131 -2
- package/asset_catalog.py +159 -0
- package/assets/base_template.pptx +0 -0
- package/assets/cover_deco.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/slide2_right.png +0 -0
- package/assets/theme_apricot.jpg +0 -0
- package/assets/theme_blush.jpg +0 -0
- package/assets/theme_coral.jpg +0 -0
- package/assets/theme_glow.jpg +0 -0
- package/assets/theme_mauve.jpg +0 -0
- package/assets/theme_peach.jpg +0 -0
- package/assets/theme_rose.jpg +0 -0
- package/assets/theme_sunset.jpg +0 -0
- package/assets/topright_deco.png +0 -0
- package/build_deck.py +2082 -0
- package/charts.py +329 -0
- package/check.py +236 -0
- package/config.json +5 -0
- package/contact_sheet.py +88 -0
- package/cordis.patch.yml +457 -0
- package/deck_baseline.py +335 -0
- package/delivery.py +143 -0
- package/density-recipe.md +110 -0
- package/example.json +26 -0
- package/formula.py +125 -0
- package/gen_image.py +395 -0
- package/generate_consensus.py +892 -0
- package/generate_ppt.py +1356 -0
- package/grasp_evidence.py +150 -0
- package/image_check.py +148 -0
- package/image_fetch.py +253 -0
- package/ingest.py +93 -0
- package/media.py +470 -0
- package/package.json +68 -4
- package/preset.yml +2 -0
- package/quality_check.py +196 -0
- package/render_ppt.py +91 -0
- package/requirements.txt +7 -0
- package/restyle.py +80 -0
- package/run_pptfast.sh +27 -0
- package/run_py.sh +43 -0
- package/slide_ops.py +160 -0
- package/slide_patch.py +153 -0
- package/speaker_notes.py +245 -0
- package/style-recipes.md +166 -0
- package/video_gif.py +164 -0
- package/vision_check.py +79 -0
package/quality_check.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-fast structural quality gate for generated PowerPoint decks.
|
|
3
|
+
|
|
4
|
+
This is deliberately independent of LibreOffice: it catches the common failure
|
|
5
|
+
where an agent delivers a text-only ``pptx_create`` draft before visual review.
|
|
6
|
+
It does not replace rendering; it prevents an obviously incomplete deck from
|
|
7
|
+
being presented as finished when rendering is unavailable.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pptx import Presentation
|
|
18
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def inspect(path: Path) -> dict:
|
|
22
|
+
prs = Presentation(str(path))
|
|
23
|
+
slides = []
|
|
24
|
+
for index, slide in enumerate(prs.slides, 1):
|
|
25
|
+
images = 0
|
|
26
|
+
tables = 0
|
|
27
|
+
charts = 0
|
|
28
|
+
text_boxes = 0
|
|
29
|
+
auto_shapes = 0
|
|
30
|
+
diagram_shapes = 0
|
|
31
|
+
picture_area = 0
|
|
32
|
+
slide_area = prs.slide_width * prs.slide_height
|
|
33
|
+
for shape in slide.shapes:
|
|
34
|
+
if shape.shape_type == 13: # MSO_SHAPE_TYPE.PICTURE
|
|
35
|
+
images += 1
|
|
36
|
+
picture_area += (shape.width or 0) * (shape.height or 0)
|
|
37
|
+
elif getattr(shape, "has_table", False):
|
|
38
|
+
tables += 1
|
|
39
|
+
elif getattr(shape, "has_chart", False):
|
|
40
|
+
charts += 1
|
|
41
|
+
elif shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE:
|
|
42
|
+
auto_shapes += 1
|
|
43
|
+
area_ratio = shape.width * shape.height / (prs.slide_width * prs.slide_height)
|
|
44
|
+
if 0.01 <= area_ratio <= 0.75:
|
|
45
|
+
diagram_shapes += 1
|
|
46
|
+
if getattr(shape, "has_text_frame", False) and shape.text.strip():
|
|
47
|
+
text_boxes += 1
|
|
48
|
+
slides.append(
|
|
49
|
+
{
|
|
50
|
+
"slide": index,
|
|
51
|
+
"images": images,
|
|
52
|
+
"tables": tables,
|
|
53
|
+
"charts": charts,
|
|
54
|
+
"text_boxes": text_boxes,
|
|
55
|
+
"auto_shapes": auto_shapes,
|
|
56
|
+
"diagram_shapes": diagram_shapes,
|
|
57
|
+
"picture_coverage": round(picture_area / slide_area, 3) if slide_area else 0,
|
|
58
|
+
"shapes": len(slide.shapes),
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
width = prs.slide_width / 914400
|
|
63
|
+
height = prs.slide_height / 914400
|
|
64
|
+
visual_slides = sum(
|
|
65
|
+
1 for item in slides
|
|
66
|
+
if item["images"] or item["tables"] or item["charts"] or item["diagram_shapes"] >= 3
|
|
67
|
+
)
|
|
68
|
+
signatures = {
|
|
69
|
+
(
|
|
70
|
+
item["images"],
|
|
71
|
+
item["tables"],
|
|
72
|
+
item["charts"],
|
|
73
|
+
min(item["text_boxes"], 14),
|
|
74
|
+
min(item["auto_shapes"], 8),
|
|
75
|
+
min(item["shapes"], 16),
|
|
76
|
+
)
|
|
77
|
+
for item in slides
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
"file": str(path),
|
|
81
|
+
"slides": len(slides),
|
|
82
|
+
"width_in": round(width, 3),
|
|
83
|
+
"height_in": round(height, 3),
|
|
84
|
+
"aspect_ratio": round(width / height, 4) if height else None,
|
|
85
|
+
"visual_slides": visual_slides,
|
|
86
|
+
"layout_signatures": len(signatures),
|
|
87
|
+
"slide_stats": slides,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def failures(report: dict) -> list[str]:
|
|
92
|
+
problems: list[str] = []
|
|
93
|
+
if report["slides"] >= 5:
|
|
94
|
+
if abs(report["aspect_ratio"] - 16 / 9) > 0.03:
|
|
95
|
+
problems.append(
|
|
96
|
+
f"页面比例为 {report['width_in']}×{report['height_in']},不是 16:9"
|
|
97
|
+
)
|
|
98
|
+
required_visual = max(3, math.ceil(report["slides"] * 0.4))
|
|
99
|
+
if report["visual_slides"] < required_visual:
|
|
100
|
+
problems.append(
|
|
101
|
+
f"仅 {report['visual_slides']}/{report['slides']} 页含图片、表格、图表或原生图示,至少需要 {required_visual} 页"
|
|
102
|
+
)
|
|
103
|
+
if report["layout_signatures"] < 4:
|
|
104
|
+
problems.append(
|
|
105
|
+
f"仅检测到 {report['layout_signatures']} 种页面结构,至少需要 4 种"
|
|
106
|
+
)
|
|
107
|
+
text_only = [
|
|
108
|
+
item["slide"]
|
|
109
|
+
for item in report["slide_stats"]
|
|
110
|
+
if not (item["images"] or item["tables"] or item["charts"])
|
|
111
|
+
and item["text_boxes"] <= 3
|
|
112
|
+
and item["diagram_shapes"] == 0
|
|
113
|
+
]
|
|
114
|
+
if len(text_only) > max(1, report["slides"] // 4):
|
|
115
|
+
problems.append(
|
|
116
|
+
"以下页面几乎只有文字框,疑似未完成排版:" + ", ".join(map(str, text_only))
|
|
117
|
+
)
|
|
118
|
+
image_only = [
|
|
119
|
+
item["slide"]
|
|
120
|
+
for item in report["slide_stats"]
|
|
121
|
+
if item["images"] and item["picture_coverage"] >= 0.95
|
|
122
|
+
and item["text_boxes"] == 0 and not (item["tables"] or item["charts"])
|
|
123
|
+
]
|
|
124
|
+
if image_only:
|
|
125
|
+
problems.append(
|
|
126
|
+
"以下页面是整页图片、没有可编辑文字(疑似 AI 工具导出的图片稿):"
|
|
127
|
+
+ ", ".join(map(str, image_only))
|
|
128
|
+
)
|
|
129
|
+
return problems
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def compare_template(report: dict, template_path: Path) -> list[str]:
|
|
133
|
+
"""Require each output page to retain its source template's structure."""
|
|
134
|
+
baseline = inspect(template_path)
|
|
135
|
+
problems: list[str] = []
|
|
136
|
+
if report["slides"] != baseline["slides"]:
|
|
137
|
+
problems.append(
|
|
138
|
+
f"模板页数为 {baseline['slides']},输出为 {report['slides']},模板页未完整继承"
|
|
139
|
+
)
|
|
140
|
+
return problems
|
|
141
|
+
source = Presentation(str(template_path))
|
|
142
|
+
result = Presentation(report["file"])
|
|
143
|
+
for index, (before, after, source_slide, result_slide) in enumerate(
|
|
144
|
+
zip(baseline["slide_stats"], report["slide_stats"], source.slides, result.slides), 1
|
|
145
|
+
):
|
|
146
|
+
source_types = [int(shape.shape_type) for shape in source_slide.shapes]
|
|
147
|
+
result_types = [int(shape.shape_type) for shape in result_slide.shapes]
|
|
148
|
+
if source_types != result_types:
|
|
149
|
+
problems.append(f"第 {index} 页形状结构与模板不一致")
|
|
150
|
+
if source_slide.slide_layout.name != result_slide.slide_layout.name:
|
|
151
|
+
problems.append(f"第 {index} 页布局与模板不一致")
|
|
152
|
+
if before["images"] != after["images"]:
|
|
153
|
+
problems.append(f"第 {index} 页图片数量与模板不一致")
|
|
154
|
+
return problems
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main() -> int:
|
|
158
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
159
|
+
parser.add_argument("pptx", type=Path)
|
|
160
|
+
parser.add_argument("--json", action="store_true")
|
|
161
|
+
parser.add_argument("--template", type=Path,
|
|
162
|
+
help="compare page count, layout, shapes and image count against a source template")
|
|
163
|
+
args = parser.parse_args()
|
|
164
|
+
try:
|
|
165
|
+
report = inspect(args.pptx)
|
|
166
|
+
except Exception as exc: # clear failure for the agent, without a traceback wall
|
|
167
|
+
print(f"quality_check: 无法读取 {args.pptx}: {exc}", file=sys.stderr)
|
|
168
|
+
return 2
|
|
169
|
+
problems = failures(report)
|
|
170
|
+
if args.template:
|
|
171
|
+
if not args.template.is_file():
|
|
172
|
+
print(f"quality_check: template not found: {args.template}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
template_problems = compare_template(report, args.template)
|
|
175
|
+
report["template"] = str(args.template.resolve())
|
|
176
|
+
report["template_failures"] = template_problems
|
|
177
|
+
problems.extend(template_problems)
|
|
178
|
+
if args.json:
|
|
179
|
+
report["failures"] = problems
|
|
180
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
181
|
+
else:
|
|
182
|
+
print(
|
|
183
|
+
f"页数={report['slides']} 比例={report['width_in']}×{report['height_in']} "
|
|
184
|
+
f"视觉页={report['visual_slides']} 版式签名={report['layout_signatures']}"
|
|
185
|
+
)
|
|
186
|
+
if problems:
|
|
187
|
+
print("FAIL")
|
|
188
|
+
for problem in problems:
|
|
189
|
+
print(f"- {problem}")
|
|
190
|
+
else:
|
|
191
|
+
print("PASS")
|
|
192
|
+
return 1 if problems else 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
raise SystemExit(main())
|
package/render_ppt.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a PPTX to PDF and PNG pages for visual review.
|
|
3
|
+
|
|
4
|
+
Uses a system ``soffice`` when available, then the user-scoped official
|
|
5
|
+
LibreOffice bundle installed by the setup script. The output stays beside the
|
|
6
|
+
input deck so foreground/background jobs see the same files.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_soffice() -> Path | None:
|
|
19
|
+
found = shutil.which("soffice") or shutil.which("libreoffice")
|
|
20
|
+
candidates = [
|
|
21
|
+
Path.home()
|
|
22
|
+
/ ".local/share/dsh-tools/libreoffice-official/opt/libreoffice26.8/opt/libreoffice26.8/program/soffice",
|
|
23
|
+
Path.home() / ".local/share/dsh-tools/libreoffice/usr/lib/libreoffice/program/soffice",
|
|
24
|
+
]
|
|
25
|
+
if found:
|
|
26
|
+
candidates.insert(0, Path(found))
|
|
27
|
+
return next((path for path in candidates if path.is_file() and os.access(path, os.X_OK)), None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> int:
|
|
31
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
32
|
+
parser.add_argument("pptx", type=Path)
|
|
33
|
+
parser.add_argument("--outdir", type=Path)
|
|
34
|
+
parser.add_argument("--dpi", type=int, default=110)
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
pptx = args.pptx.resolve()
|
|
37
|
+
if not pptx.is_file():
|
|
38
|
+
print(f"render_ppt: file not found: {pptx}", file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
soffice = find_soffice()
|
|
41
|
+
if soffice is None:
|
|
42
|
+
print("render_ppt: no usable soffice/libreoffice found", file=sys.stderr)
|
|
43
|
+
return 3
|
|
44
|
+
outdir = (args.outdir or pptx.parent / ".render").resolve()
|
|
45
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
for old_page in outdir.glob("page-*.png"):
|
|
47
|
+
old_page.unlink()
|
|
48
|
+
profile = outdir / ".lo-profile"
|
|
49
|
+
profile.mkdir(exist_ok=True)
|
|
50
|
+
env = os.environ.copy()
|
|
51
|
+
env["SAL_USE_VCLPLUGIN"] = "svp"
|
|
52
|
+
command = [
|
|
53
|
+
str(soffice),
|
|
54
|
+
"--headless",
|
|
55
|
+
"--nologo",
|
|
56
|
+
"--nodefault",
|
|
57
|
+
"--nofirststartwizard",
|
|
58
|
+
f"-env:UserInstallation=file://{profile}",
|
|
59
|
+
"--convert-to",
|
|
60
|
+
"pdf",
|
|
61
|
+
"--outdir",
|
|
62
|
+
str(outdir),
|
|
63
|
+
str(pptx),
|
|
64
|
+
]
|
|
65
|
+
result = subprocess.run(command, env=env, text=True, capture_output=True)
|
|
66
|
+
if result.returncode:
|
|
67
|
+
print(result.stdout, end="")
|
|
68
|
+
print(result.stderr, end="", file=sys.stderr)
|
|
69
|
+
return result.returncode
|
|
70
|
+
pdf = outdir / f"{pptx.stem}.pdf"
|
|
71
|
+
if not pdf.is_file():
|
|
72
|
+
print(f"render_ppt: conversion completed without PDF: {pdf}", file=sys.stderr)
|
|
73
|
+
return 4
|
|
74
|
+
prefix = outdir / "page"
|
|
75
|
+
subprocess.run(["pdftoppm", "-png", "-r", str(args.dpi), str(pdf), str(prefix)], check=True)
|
|
76
|
+
raw_pages = sorted(outdir.glob("page-[0-9]*.png"))
|
|
77
|
+
staging = []
|
|
78
|
+
for index, raw_page in enumerate(raw_pages, 1):
|
|
79
|
+
temporary = outdir / f".render-page-{index}.png"
|
|
80
|
+
raw_page.rename(temporary)
|
|
81
|
+
staging.append(temporary)
|
|
82
|
+
for index, temporary in enumerate(staging, 1):
|
|
83
|
+
temporary.rename(outdir / f"page-{index:02d}.png")
|
|
84
|
+
print(f"renderer={soffice}")
|
|
85
|
+
print(f"pdf={pdf}")
|
|
86
|
+
print(f"pages={len(staging)}")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|
package/requirements.txt
ADDED
package/restyle.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
restyle.py —— 把已有 PPT 原地「套用模板风格」:统一字体与配色,输出新文件。
|
|
4
|
+
(排版级重排/内容改动走 ingest → 规格 → generate 的工作流。)
|
|
5
|
+
|
|
6
|
+
规则:
|
|
7
|
+
- 字体:字号 >= 20pt 视为标题 → 华文中宋;否则正文 → Noto Serif SC。
|
|
8
|
+
- 配色:黑色及非白文本 → 主色藏蓝 #192D77;白色文本保留(避免深色背景上文字消失)。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python3 restyle.py <input.pptx> <output.pptx>
|
|
12
|
+
"""
|
|
13
|
+
import sys
|
|
14
|
+
from pptx import Presentation
|
|
15
|
+
from pptx.dml.color import RGBColor
|
|
16
|
+
from pptx.oxml.ns import qn
|
|
17
|
+
|
|
18
|
+
NAVY = RGBColor(0x19, 0x2D, 0x77)
|
|
19
|
+
TITLE_FONT = "华文中宋"
|
|
20
|
+
BODY_FONT = "Noto Serif SC"
|
|
21
|
+
|
|
22
|
+
# 模板调色板(这些颜色保留,不归一化)
|
|
23
|
+
PALETTE = {'192D77', 'C00000', 'D24726'}
|
|
24
|
+
|
|
25
|
+
def _set_font(run, name):
|
|
26
|
+
run.font.name = name
|
|
27
|
+
rPr = run._r.get_or_add_rPr()
|
|
28
|
+
for tag in ('a:latin', 'a:ea', 'a:cs'):
|
|
29
|
+
e = rPr.find(qn(tag))
|
|
30
|
+
if e is None:
|
|
31
|
+
e = rPr.makeelement(qn(tag), {})
|
|
32
|
+
rPr.append(e)
|
|
33
|
+
e.set('typeface', name)
|
|
34
|
+
|
|
35
|
+
def _rgb_str(run):
|
|
36
|
+
"""返回 run 当前实色 'RRGGBB',theme/自动色等返回 None。"""
|
|
37
|
+
try:
|
|
38
|
+
s = str(run.font.color.rgb)
|
|
39
|
+
return s if len(s) == 6 else None
|
|
40
|
+
except Exception:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def _is_whiteish(hex6):
|
|
44
|
+
r, g, b = int(hex6[0:2], 16), int(hex6[2:4], 16), int(hex6[4:6], 16)
|
|
45
|
+
return r > 220 and g > 220 and b > 220
|
|
46
|
+
|
|
47
|
+
def restyle(in_path, out_path):
|
|
48
|
+
prs = Presentation(in_path)
|
|
49
|
+
stats = {'runs': 0, 'font': 0, 'color': 0, 'title': 0, 'body': 0}
|
|
50
|
+
for slide in prs.slides:
|
|
51
|
+
for shape in slide.shapes:
|
|
52
|
+
if not shape.has_text_frame:
|
|
53
|
+
continue
|
|
54
|
+
for para in shape.text_frame.paragraphs:
|
|
55
|
+
for run in para.runs:
|
|
56
|
+
if not run.text.strip():
|
|
57
|
+
continue
|
|
58
|
+
stats['runs'] += 1
|
|
59
|
+
size = run.font.size.pt if run.font.size else 18
|
|
60
|
+
name = TITLE_FONT if size >= 20 else BODY_FONT
|
|
61
|
+
if size >= 20:
|
|
62
|
+
stats['title'] += 1
|
|
63
|
+
else:
|
|
64
|
+
stats['body'] += 1
|
|
65
|
+
_set_font(run, name)
|
|
66
|
+
stats['font'] += 1
|
|
67
|
+
hex6 = _rgb_str(run)
|
|
68
|
+
if hex6 and not _is_whiteish(hex6) and hex6.upper() not in PALETTE:
|
|
69
|
+
run.font.color.rgb = NAVY
|
|
70
|
+
stats['color'] += 1
|
|
71
|
+
prs.save(out_path)
|
|
72
|
+
return stats
|
|
73
|
+
|
|
74
|
+
if __name__ == '__main__':
|
|
75
|
+
if len(sys.argv) < 3:
|
|
76
|
+
print("用法: python3 restyle.py <input.pptx> <output.pptx>")
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
s = restyle(sys.argv[1], sys.argv[2])
|
|
79
|
+
print(f"已重排: {sys.argv[1]} -> {sys.argv[2]}")
|
|
80
|
+
print(f" 处理文本段 {s['runs']},标题 {s['title']},正文 {s['body']},改字体 {s['font']},改配色 {s['color']}")
|
package/run_pptfast.sh
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Run the installed pptfast package without importing or executing workspace code.
|
|
5
|
+
# Desktop deployments historically keep it in ~/ppt-assistant/node_modules; the
|
|
6
|
+
# repository checkout keeps the same package layout beside this launcher.
|
|
7
|
+
engine_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
8
|
+
candidate_paths=(
|
|
9
|
+
"$engine_root/node_modules/@liustack/pptfast/dist/cli.js"
|
|
10
|
+
"${HOME}/ppt-assistant/node_modules/@liustack/pptfast/dist/cli.js"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
for cli in "${candidate_paths[@]}"; do
|
|
14
|
+
if [[ -f "$cli" ]]; then
|
|
15
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
16
|
+
echo "pptfast: 找到运行包但未找到 Node.js(需要 Node 22.19+)" >&2
|
|
17
|
+
exit 78
|
|
18
|
+
fi
|
|
19
|
+
exec node "$cli" "$@"
|
|
20
|
+
fi
|
|
21
|
+
done
|
|
22
|
+
|
|
23
|
+
cat >&2 <<'EOF'
|
|
24
|
+
pptfast: 未找到已安装的 @liustack/pptfast 运行包。
|
|
25
|
+
请在部署环境安装 @liustack/pptfast@0.20.0,或使用内置 build_deck.py;不要执行用户工作区脚本。
|
|
26
|
+
EOF
|
|
27
|
+
exit 78
|
package/run_py.sh
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run a PPT-engine Python script with an interpreter that actually has the
|
|
3
|
+
# deck dependencies. DSH's non-login shell may resolve `python3` to a runtime
|
|
4
|
+
# without python-pptx; this picks the first usable interpreter instead.
|
|
5
|
+
#
|
|
6
|
+
# bash <engine-root>/run_py.sh build_deck.py spec.json out.pptx
|
|
7
|
+
# PPT_PYTHON=/path/to/python bash <engine-root>/run_py.sh quality_check.py deck.pptx
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
if [[ $# -lt 1 ]]; then
|
|
11
|
+
echo "run_py: usage: run_py.sh <script.py> [args...]" >&2
|
|
12
|
+
exit 64
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
engine_root=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
16
|
+
script=$1
|
|
17
|
+
shift
|
|
18
|
+
[[ -f "$script" || -f "$engine_root/$script" ]] || { echo "run_py: script not found: $script" >&2; exit 66; }
|
|
19
|
+
|
|
20
|
+
candidates=()
|
|
21
|
+
[[ -n "${PPT_PYTHON:-}" ]] && candidates+=("$PPT_PYTHON")
|
|
22
|
+
candidates+=(
|
|
23
|
+
"${CONDA_PREFIX:-}/bin/python"
|
|
24
|
+
"$HOME/anaconda3/bin/python"
|
|
25
|
+
"$HOME/miniconda3/bin/python"
|
|
26
|
+
"python3"
|
|
27
|
+
"python3.13"
|
|
28
|
+
"python3.12"
|
|
29
|
+
"python3.11"
|
|
30
|
+
"python3.10"
|
|
31
|
+
"python3.9"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
for python in "${candidates[@]}"; do
|
|
35
|
+
[[ -n "$python" ]] || continue
|
|
36
|
+
command -v "$python" >/dev/null 2>&1 || continue
|
|
37
|
+
if "$python" -c 'import pptx, PIL' >/dev/null 2>&1; then
|
|
38
|
+
exec "$python" "$script" "$@"
|
|
39
|
+
fi
|
|
40
|
+
done
|
|
41
|
+
|
|
42
|
+
echo "run_py: 没有找到带 python-pptx/Pillow 的解释器。请安装 requirements.txt,或设置 PPT_PYTHON。" >&2
|
|
43
|
+
exit 69
|
package/slide_ops.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Copy editable slides between presentations while retaining media and notes."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import copy
|
|
7
|
+
import json
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from zipfile import ZipFile
|
|
11
|
+
|
|
12
|
+
from pptx import Presentation
|
|
13
|
+
from pptx.opc.packuri import PackURI
|
|
14
|
+
|
|
15
|
+
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _remap_relationship_ids(element, mapping: dict[str, str]) -> None:
|
|
19
|
+
"""Replace relationship ids in a copied slide XML tree."""
|
|
20
|
+
for node in element.iter():
|
|
21
|
+
for attribute, value in list(node.attrib.items()):
|
|
22
|
+
if attribute.startswith(f"{{{R}}}") and value in mapping:
|
|
23
|
+
node.set(attribute, mapping[value])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def copy_slide(source_slide, target_prs: Presentation):
|
|
27
|
+
"""Append one source slide to ``target_prs`` using the target's layout/theme."""
|
|
28
|
+
layout = target_prs.slide_layouts.get_by_name(source_slide.slide_layout.name)
|
|
29
|
+
if layout is None:
|
|
30
|
+
layout = target_prs.slide_layouts[-1]
|
|
31
|
+
target = target_prs.slides.add_slide(layout)
|
|
32
|
+
target_rels = target.part.rels
|
|
33
|
+
relationship_ids: dict[str, str] = {}
|
|
34
|
+
for relationship in source_slide.part.rels.values():
|
|
35
|
+
if relationship.reltype.endswith(("/slideLayout", "/notesSlide")):
|
|
36
|
+
continue
|
|
37
|
+
relationship_ids[relationship.rId] = target_rels._add_relationship(
|
|
38
|
+
relationship.reltype, relationship._target, relationship.is_external
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
copied = copy.deepcopy(source_slide._element)
|
|
42
|
+
_remap_relationship_ids(copied, relationship_ids)
|
|
43
|
+
destination = target._element
|
|
44
|
+
for child in list(destination):
|
|
45
|
+
destination.remove(child)
|
|
46
|
+
for child in copied:
|
|
47
|
+
destination.append(child)
|
|
48
|
+
|
|
49
|
+
if source_slide.has_notes_slide and source_slide.notes_slide.notes_text_frame.text:
|
|
50
|
+
target.notes_slide.notes_text_frame.text = source_slide.notes_slide.notes_text_frame.text
|
|
51
|
+
return target
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def import_dependencies(source_prs, target_prs, pages):
|
|
55
|
+
"""Rename imported parts to avoid ZIP collisions; reject internal slide links.
|
|
56
|
+
|
|
57
|
+
Images, charts and their embedded workbooks may share filenames with the
|
|
58
|
+
target. Each imported graph retains sharing but receives a unique partname.
|
|
59
|
+
Slide navigation needs an explicit page mapping and is therefore rejected.
|
|
60
|
+
"""
|
|
61
|
+
occupied = {str(part.partname) for part in target_prs.part.package.iter_parts()}
|
|
62
|
+
visited = set()
|
|
63
|
+
|
|
64
|
+
def visit(part):
|
|
65
|
+
if part in visited:
|
|
66
|
+
return
|
|
67
|
+
visited.add(part)
|
|
68
|
+
original = str(part.partname)
|
|
69
|
+
name = original
|
|
70
|
+
number = 1
|
|
71
|
+
path = Path(original)
|
|
72
|
+
while name in occupied:
|
|
73
|
+
name = str(path.with_name(f"{path.stem}_copy{number}{path.suffix}"))
|
|
74
|
+
number += 1
|
|
75
|
+
occupied.add(name)
|
|
76
|
+
part._partname = PackURI(name)
|
|
77
|
+
for relationship in part.rels.values():
|
|
78
|
+
if relationship.is_external:
|
|
79
|
+
continue
|
|
80
|
+
if relationship.reltype.endswith("/slide"):
|
|
81
|
+
raise ValueError("internal slide links require an explicit page mapping")
|
|
82
|
+
visit(relationship.target_part)
|
|
83
|
+
|
|
84
|
+
for page in pages:
|
|
85
|
+
for relationship in source_prs.slides[page - 1].part.rels.values():
|
|
86
|
+
if relationship.is_external or relationship.reltype.endswith(("/slideLayout", "/notesSlide")):
|
|
87
|
+
continue
|
|
88
|
+
if relationship.reltype.endswith("/slide"):
|
|
89
|
+
raise ValueError("internal slide links require an explicit page mapping")
|
|
90
|
+
visit(relationship.target_part)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def copy_slides(source: Path, target: Path, output: Path, pages: list[int], position: int | None = None) -> dict:
|
|
94
|
+
"""Copy selected 1-based pages from ``source`` into ``target`` atomically.
|
|
95
|
+
|
|
96
|
+
The target's slide layout and theme remain authoritative. Source media,
|
|
97
|
+
charts, external hyperlinks and editable shape XML are copied with relationship ids
|
|
98
|
+
remapped; source notes are copied when present. ``position`` is a zero-based
|
|
99
|
+
insertion point in the target's existing slide list, defaulting to append.
|
|
100
|
+
"""
|
|
101
|
+
if output.resolve() in {source.resolve(), target.resolve()}:
|
|
102
|
+
raise ValueError("output must be separate from both input presentations")
|
|
103
|
+
if not pages or any(type(page) is not int or page < 1 for page in pages):
|
|
104
|
+
raise ValueError("pages must contain positive 1-based integers")
|
|
105
|
+
source_prs = Presentation(source)
|
|
106
|
+
target_prs = Presentation(target)
|
|
107
|
+
if any(page > len(source_prs.slides) for page in pages):
|
|
108
|
+
raise ValueError("source page is outside the presentation")
|
|
109
|
+
if position is not None and not 0 <= position <= len(target_prs.slides):
|
|
110
|
+
raise ValueError("position must be within the target slide list")
|
|
111
|
+
if (source_prs.slide_width, source_prs.slide_height) != (target_prs.slide_width, target_prs.slide_height):
|
|
112
|
+
raise ValueError("source and target slide dimensions must match")
|
|
113
|
+
|
|
114
|
+
import_dependencies(source_prs, target_prs, pages)
|
|
115
|
+
copied = [copy_slide(source_prs.slides[page - 1], target_prs) for page in pages]
|
|
116
|
+
|
|
117
|
+
def slide_id(slide):
|
|
118
|
+
return next(item for item in target_prs.slides._sldIdLst
|
|
119
|
+
if target_prs.part.related_part(item.rId).partname == slide.part.partname)
|
|
120
|
+
|
|
121
|
+
if position is not None:
|
|
122
|
+
ids = target_prs.slides._sldIdLst
|
|
123
|
+
copied_ids = [slide_id(slide) for slide in copied]
|
|
124
|
+
for copied_id in copied_ids:
|
|
125
|
+
ids.remove(copied_id)
|
|
126
|
+
anchor = list(ids)[position] if position < len(ids) else None
|
|
127
|
+
for copied_id in copied_ids:
|
|
128
|
+
if anchor is None:
|
|
129
|
+
ids.append(copied_id)
|
|
130
|
+
else:
|
|
131
|
+
ids.insert(ids.index(anchor), copied_id)
|
|
132
|
+
|
|
133
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
with tempfile.TemporaryDirectory(dir=output.parent, prefix=".slide-ops-") as directory:
|
|
135
|
+
temporary = Path(directory) / "presentation.pptx"
|
|
136
|
+
target_prs.save(temporary)
|
|
137
|
+
with ZipFile(temporary) as archive:
|
|
138
|
+
if len(archive.namelist()) != len(set(archive.namelist())) or archive.testzip():
|
|
139
|
+
raise ValueError("copied deck contains invalid or duplicate ZIP entries")
|
|
140
|
+
readback = Presentation(temporary)
|
|
141
|
+
if len(readback.slides) != len(target_prs.slides):
|
|
142
|
+
raise ValueError("slide count changed during save")
|
|
143
|
+
temporary.replace(output)
|
|
144
|
+
return {"source_pages": pages, "copied": len(pages), "output_pages": len(target_prs.slides)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main() -> int:
|
|
148
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
149
|
+
parser.add_argument("source", type=Path, help="presentation containing reusable pages")
|
|
150
|
+
parser.add_argument("target", type=Path, help="presentation receiving the pages")
|
|
151
|
+
parser.add_argument("output", type=Path)
|
|
152
|
+
parser.add_argument("--pages", type=int, nargs="+", required=True)
|
|
153
|
+
parser.add_argument("--position", type=int, help="zero-based insertion point; default appends")
|
|
154
|
+
args = parser.parse_args()
|
|
155
|
+
print(json.dumps(copy_slides(args.source, args.target, args.output, args.pages, args.position)))
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
raise SystemExit(main())
|