@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/asset_catalog.py +159 -0
  4. package/assets/base_template.pptx +0 -0
  5. package/assets/cover_deco.png +0 -0
  6. package/assets/logo.png +0 -0
  7. package/assets/slide2_right.png +0 -0
  8. package/assets/theme_apricot.jpg +0 -0
  9. package/assets/theme_blush.jpg +0 -0
  10. package/assets/theme_coral.jpg +0 -0
  11. package/assets/theme_glow.jpg +0 -0
  12. package/assets/theme_mauve.jpg +0 -0
  13. package/assets/theme_peach.jpg +0 -0
  14. package/assets/theme_rose.jpg +0 -0
  15. package/assets/theme_sunset.jpg +0 -0
  16. package/assets/topright_deco.png +0 -0
  17. package/build_deck.py +2082 -0
  18. package/charts.py +329 -0
  19. package/check.py +236 -0
  20. package/config.json +5 -0
  21. package/contact_sheet.py +88 -0
  22. package/cordis.patch.yml +457 -0
  23. package/deck_baseline.py +335 -0
  24. package/delivery.py +143 -0
  25. package/density-recipe.md +110 -0
  26. package/example.json +26 -0
  27. package/formula.py +125 -0
  28. package/gen_image.py +395 -0
  29. package/generate_consensus.py +892 -0
  30. package/generate_ppt.py +1356 -0
  31. package/grasp_evidence.py +150 -0
  32. package/image_check.py +148 -0
  33. package/image_fetch.py +253 -0
  34. package/ingest.py +93 -0
  35. package/media.py +470 -0
  36. package/package.json +68 -4
  37. package/preset.yml +2 -0
  38. package/quality_check.py +196 -0
  39. package/render_ppt.py +91 -0
  40. package/requirements.txt +7 -0
  41. package/restyle.py +80 -0
  42. package/run_pptfast.sh +27 -0
  43. package/run_py.sh +43 -0
  44. package/slide_ops.py +160 -0
  45. package/slide_patch.py +153 -0
  46. package/speaker_notes.py +245 -0
  47. package/style-recipes.md +166 -0
  48. package/video_gif.py +164 -0
  49. package/vision_check.py +79 -0
@@ -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())
@@ -0,0 +1,7 @@
1
+ Pillow>=10.0
2
+ python-pptx>=0.6.23
3
+ pypdf>=5.0
4
+ numpy>=1.26
5
+ lxml>=4.9
6
+ matplotlib>=3.7
7
+ requests>=2.28
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())