@cteno/sdk-linux-x64 0.0.0-stage → 2.10.14
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.daemon +31 -0
- package/README.md +4 -2
- package/agents/flow-analyzer/AGENT.md +71 -0
- package/bin/cteno-host-cli +4 -0
- package/bin/cteno-wiki +0 -0
- package/bin/rg +0 -0
- package/bin/secret-exec +0 -0
- package/manifest.json +12 -0
- package/package.json +33 -4
- package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
- package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
- package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
- package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
- package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
- package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
- package/plugins/cteno.inbox/.cteno-plugin/plugin.json +132 -0
- package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
- package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
- package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
- package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
- package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
- package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
- package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
- package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
- package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
- package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
- package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
- package/plugins/cteno.memory/ui/index.html +822 -0
- package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
- package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
- package/plugins/jev/.cteno-plugin/plugin.json +309 -0
- package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
- package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
- package/skills/agent-create/SKILL.md +86 -0
- package/skills/cteno-host-cli/SKILL.md +256 -0
- package/skills/docx/LICENSE +23 -0
- package/skills/docx/NOTICE.md +8 -0
- package/skills/docx/SKILL.md +69 -0
- package/skills/docx/references/cjk_typography.md +357 -0
- package/skills/docx/references/design_principles.md +819 -0
- package/skills/docx/references/revisions-and-comments.md +88 -0
- package/skills/docx/references/track_changes_guide.md +200 -0
- package/skills/docx/references/typography_guide.md +294 -0
- package/skills/docx/scripts/cjk_fonts.py +244 -0
- package/skills/docx/scripts/docx_comments.py +289 -0
- package/skills/docx/scripts/docx_common.py +92 -0
- package/skills/docx/scripts/docx_create.py +259 -0
- package/skills/docx/scripts/docx_edit.py +250 -0
- package/skills/docx/scripts/docx_read.py +149 -0
- package/skills/docx/scripts/docx_revisions.py +146 -0
- package/skills/docx/scripts/docx_template.py +70 -0
- package/skills/docx/scripts/docx_validate.py +156 -0
- package/skills/docx/scripts/render.py +250 -0
- package/skills/docx/tests/test_docx_skill.py +525 -0
- package/skills/home-devices/SKILL.md +121 -0
- package/skills/mcp/SKILL.md +72 -0
- package/skills/mcp/scripts/mcp_setup.py +439 -0
- package/skills/pdf/LICENSE +23 -0
- package/skills/pdf/NOTICE.md +8 -0
- package/skills/pdf/SKILL.md +63 -0
- package/skills/pdf/references/design-tokens.md +381 -0
- package/skills/pdf/references/forms.md +99 -0
- package/skills/pdf/references/nano-pdf-editing.md +40 -0
- package/skills/pdf/references/ocr-extraction.md +165 -0
- package/skills/pdf/scripts/_raster.py +76 -0
- package/skills/pdf/scripts/extract_marker.py +88 -0
- package/skills/pdf/scripts/extract_pymupdf.py +101 -0
- package/skills/pdf/scripts/pdf_create.py +191 -0
- package/skills/pdf/scripts/pdf_fill_form.py +97 -0
- package/skills/pdf/scripts/pdf_form_layout.py +168 -0
- package/skills/pdf/scripts/pdf_make_form.py +145 -0
- package/skills/pdf/scripts/pdf_merge.py +50 -0
- package/skills/pdf/scripts/pdf_meta.py +115 -0
- package/skills/pdf/scripts/pdf_page_image.py +99 -0
- package/skills/pdf/scripts/pdf_read.py +153 -0
- package/skills/pdf/scripts/pdf_secure.py +71 -0
- package/skills/pdf/scripts/pdf_split.py +84 -0
- package/skills/pdf/scripts/pdf_stamp.py +143 -0
- package/skills/pdf/scripts/pdf_watermark.py +51 -0
- package/skills/pdf/scripts/render.py +250 -0
- package/skills/pdf/tests/test_pdf_skill.py +415 -0
- package/skills/pptx/LICENSE +23 -0
- package/skills/pptx/NOTICE.md +8 -0
- package/skills/pptx/SKILL.md +62 -0
- package/skills/pptx/references/design-system.md +392 -0
- package/skills/pptx/references/pptxgenjs.md +420 -0
- package/skills/pptx/references/slide-types.md +413 -0
- package/skills/pptx/scripts/cjk_fonts.py +244 -0
- package/skills/pptx/scripts/pptx_create.py +240 -0
- package/skills/pptx/scripts/pptx_edit.py +436 -0
- package/skills/pptx/scripts/pptx_from_template.py +88 -0
- package/skills/pptx/scripts/pptx_read.py +131 -0
- package/skills/pptx/scripts/pptx_render.py +96 -0
- package/skills/pptx/scripts/render.py +250 -0
- package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
- package/skills/skill-create/SKILL.md +114 -0
- package/skills/video-generation/SKILL.md +27 -0
- package/skills/xlsx/LICENSE +23 -0
- package/skills/xlsx/NOTICE.md +8 -0
- package/skills/xlsx/SKILL.md +63 -0
- package/skills/xlsx/references/format.md +71 -0
- package/skills/xlsx/references/restructuring.md +71 -0
- package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
- package/skills/xlsx/scripts/formula_check.py +428 -0
- package/skills/xlsx/scripts/render.py +250 -0
- package/skills/xlsx/scripts/xlsx_create.py +265 -0
- package/skills/xlsx/scripts/xlsx_edit.py +269 -0
- package/skills/xlsx/scripts/xlsx_read.py +166 -0
- package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
- package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
- package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
- package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
- package/tools/create_goal/TOOL.md +36 -0
- package/tools/image_generation/TOOL.md +81 -0
- package/tools/skill/TOOL.md +117 -0
- package/tools/update_profile_notes/TOOL.md +39 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a .pptx presentation from a JSON deck spec.
|
|
3
|
+
|
|
4
|
+
Spec format (all positions/sizes in inches, colors as RRGGBB hex):
|
|
5
|
+
{
|
|
6
|
+
"slide_size": "16:9", // or "4:3" (default "16:9")
|
|
7
|
+
"slides": [
|
|
8
|
+
{"layout": "title", "title": "My Deck", "subtitle": "Q3 review"},
|
|
9
|
+
{"layout": "title_content", "title": "Agenda",
|
|
10
|
+
"bullets": ["Top item",
|
|
11
|
+
{"text": "Sub item", "level": 1, "bold": true,
|
|
12
|
+
"size": 18, "color": "CC0000", "font": "Arial",
|
|
13
|
+
"italic": false,
|
|
14
|
+
"link": "https://example.com/agenda"}],
|
|
15
|
+
"background": "1F2937", // solid slide background (hex)
|
|
16
|
+
"footer": "Confidential", // footer placeholder text
|
|
17
|
+
"slide_number": true, // enable slide-number placeholder
|
|
18
|
+
"notes": "Speaker notes for this slide"},
|
|
19
|
+
{"layout": "blank", "title": "Widgets",
|
|
20
|
+
"images": [{"path": "logo.png", "left": 1, "top": 1, "width": 3}],
|
|
21
|
+
"tables": [{"left": 1, "top": 2, "width": 6, "height": 2,
|
|
22
|
+
"rows": [["H1", "H2"], ["a", "b"]]}],
|
|
23
|
+
"shapes": [{"type": "rounded_rectangle", "left": 8, "top": 1,
|
|
24
|
+
"width": 3, "height": 1, "fill": "4472C4",
|
|
25
|
+
"text": "Callout", "text_color": "FFFFFF"}],
|
|
26
|
+
"charts": [{"type": "bar", "left": 1, "top": 3, "width": 6,
|
|
27
|
+
"height": 3.5, "title": "Sales",
|
|
28
|
+
"categories": ["Q1", "Q2"],
|
|
29
|
+
"series": {"North": [10, 20], "South": [7, 13]}}]}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
CJK: Chinese/Japanese/Korean text automatically gets an East Asian font (Microsoft YaHei);
|
|
33
|
+
override with "cjk_fonts": {"body": "...", "heading": "..."} or disable with "cjk_fonts": false.
|
|
34
|
+
Layouts: title, title_content, section, two_content, title_only, blank
|
|
35
|
+
Chart types: bar, bar_h, line, pie Shape types: rectangle,
|
|
36
|
+
rounded_rectangle, oval, diamond, right_arrow, chevron
|
|
37
|
+
"""
|
|
38
|
+
import argparse
|
|
39
|
+
import copy
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
46
|
+
|
|
47
|
+
from pptx import Presentation
|
|
48
|
+
from pptx.chart.data import CategoryChartData
|
|
49
|
+
from pptx.dml.color import RGBColor
|
|
50
|
+
from pptx.enum.chart import XL_CHART_TYPE
|
|
51
|
+
from pptx.enum.shapes import MSO_SHAPE
|
|
52
|
+
from pptx.util import Inches, Pt
|
|
53
|
+
|
|
54
|
+
LAYOUTS = {"title": 0, "title_content": 1, "section": 2,
|
|
55
|
+
"two_content": 3, "title_only": 5, "blank": 6}
|
|
56
|
+
CHART_TYPES = {"bar": XL_CHART_TYPE.COLUMN_CLUSTERED,
|
|
57
|
+
"bar_h": XL_CHART_TYPE.BAR_CLUSTERED,
|
|
58
|
+
"line": XL_CHART_TYPE.LINE_MARKERS,
|
|
59
|
+
"pie": XL_CHART_TYPE.PIE}
|
|
60
|
+
SHAPE_TYPES = {"rectangle": MSO_SHAPE.RECTANGLE,
|
|
61
|
+
"rounded_rectangle": MSO_SHAPE.ROUNDED_RECTANGLE,
|
|
62
|
+
"oval": MSO_SHAPE.OVAL, "diamond": MSO_SHAPE.DIAMOND,
|
|
63
|
+
"right_arrow": MSO_SHAPE.RIGHT_ARROW,
|
|
64
|
+
"chevron": MSO_SHAPE.CHEVRON}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def style_run(run, spec):
|
|
68
|
+
"""Apply font styling from a bullet/text spec dict to a run."""
|
|
69
|
+
font = run.font
|
|
70
|
+
if spec.get("size"):
|
|
71
|
+
font.size = Pt(spec["size"])
|
|
72
|
+
if spec.get("bold") is not None:
|
|
73
|
+
font.bold = spec["bold"]
|
|
74
|
+
if spec.get("italic") is not None:
|
|
75
|
+
font.italic = spec["italic"]
|
|
76
|
+
if spec.get("font"):
|
|
77
|
+
font.name = spec["font"]
|
|
78
|
+
if spec.get("color"):
|
|
79
|
+
font.color.rgb = RGBColor.from_string(spec["color"])
|
|
80
|
+
if spec.get("link"):
|
|
81
|
+
run.hyperlink.address = spec["link"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def add_bullets(text_frame, bullets):
|
|
85
|
+
text_frame.clear()
|
|
86
|
+
for i, item in enumerate(bullets):
|
|
87
|
+
if isinstance(item, str):
|
|
88
|
+
item = {"text": item}
|
|
89
|
+
para = text_frame.paragraphs[0] if i == 0 else text_frame.add_paragraph()
|
|
90
|
+
para.level = int(item.get("level", 0))
|
|
91
|
+
run = para.add_run()
|
|
92
|
+
run.text = item.get("text", "")
|
|
93
|
+
style_run(run, item)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def copy_layout_placeholder(slide, ph_idx):
|
|
97
|
+
"""Copy a layout placeholder (footer=11, slide number=12) onto the
|
|
98
|
+
slide so it actually renders; returns the shape or None if the layout
|
|
99
|
+
does not provide it."""
|
|
100
|
+
for ph in slide.slide_layout.placeholders:
|
|
101
|
+
if ph.placeholder_format.idx == ph_idx:
|
|
102
|
+
slide.shapes._spTree.append(copy.deepcopy(ph._element))
|
|
103
|
+
for shape in slide.placeholders:
|
|
104
|
+
if shape.placeholder_format.idx == ph_idx:
|
|
105
|
+
return shape
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_slide(prs, spec):
|
|
110
|
+
layout_idx = LAYOUTS.get(spec.get("layout", "title_content"), 1)
|
|
111
|
+
slide = prs.slides.add_slide(prs.slide_layouts[layout_idx])
|
|
112
|
+
|
|
113
|
+
if spec.get("background"):
|
|
114
|
+
fill = slide.background.fill
|
|
115
|
+
fill.solid()
|
|
116
|
+
fill.fore_color.rgb = RGBColor.from_string(spec["background"])
|
|
117
|
+
if spec.get("slide_number"):
|
|
118
|
+
copy_layout_placeholder(slide, 12)
|
|
119
|
+
if spec.get("footer"):
|
|
120
|
+
shape = copy_layout_placeholder(slide, 11)
|
|
121
|
+
if shape is not None:
|
|
122
|
+
shape.text_frame.text = spec["footer"]
|
|
123
|
+
|
|
124
|
+
if spec.get("title") is not None and slide.shapes.title is not None:
|
|
125
|
+
slide.shapes.title.text = spec["title"]
|
|
126
|
+
if spec.get("subtitle") is not None:
|
|
127
|
+
for ph in slide.placeholders:
|
|
128
|
+
if ph.placeholder_format.idx == 1:
|
|
129
|
+
ph.text = spec["subtitle"]
|
|
130
|
+
break
|
|
131
|
+
if spec.get("bullets"):
|
|
132
|
+
body = next((ph for ph in slide.placeholders
|
|
133
|
+
if ph.placeholder_format.idx != 0), None)
|
|
134
|
+
if body is None:
|
|
135
|
+
body = slide.shapes.add_textbox(Inches(0.5), Inches(1.5),
|
|
136
|
+
Inches(9), Inches(5))
|
|
137
|
+
add_bullets(body.text_frame, spec["bullets"])
|
|
138
|
+
|
|
139
|
+
for img in spec.get("images", []):
|
|
140
|
+
kwargs = {}
|
|
141
|
+
if img.get("width"):
|
|
142
|
+
kwargs["width"] = Inches(img["width"])
|
|
143
|
+
if img.get("height"):
|
|
144
|
+
kwargs["height"] = Inches(img["height"])
|
|
145
|
+
slide.shapes.add_picture(img["path"], Inches(img.get("left", 1)),
|
|
146
|
+
Inches(img.get("top", 1)), **kwargs)
|
|
147
|
+
|
|
148
|
+
for tbl in spec.get("tables", []):
|
|
149
|
+
rows = tbl["rows"]
|
|
150
|
+
shape = slide.shapes.add_table(
|
|
151
|
+
len(rows), len(rows[0]), Inches(tbl.get("left", 1)),
|
|
152
|
+
Inches(tbl.get("top", 2)), Inches(tbl.get("width", 6)),
|
|
153
|
+
Inches(tbl.get("height", 2)))
|
|
154
|
+
for r, row in enumerate(rows):
|
|
155
|
+
for c, val in enumerate(row):
|
|
156
|
+
shape.table.cell(r, c).text = str(val)
|
|
157
|
+
|
|
158
|
+
for shp in spec.get("shapes", []):
|
|
159
|
+
shape = slide.shapes.add_shape(
|
|
160
|
+
SHAPE_TYPES.get(shp.get("type", "rectangle"), MSO_SHAPE.RECTANGLE),
|
|
161
|
+
Inches(shp.get("left", 1)), Inches(shp.get("top", 1)),
|
|
162
|
+
Inches(shp.get("width", 2)), Inches(shp.get("height", 1)))
|
|
163
|
+
if shp.get("fill"):
|
|
164
|
+
shape.fill.solid()
|
|
165
|
+
shape.fill.fore_color.rgb = RGBColor.from_string(shp["fill"])
|
|
166
|
+
if shp.get("text"):
|
|
167
|
+
shape.text_frame.text = shp["text"]
|
|
168
|
+
if shp.get("text_color"):
|
|
169
|
+
run = shape.text_frame.paragraphs[0].runs[0]
|
|
170
|
+
run.font.color.rgb = RGBColor.from_string(shp["text_color"])
|
|
171
|
+
|
|
172
|
+
for cht in spec.get("charts", []):
|
|
173
|
+
data = CategoryChartData()
|
|
174
|
+
data.categories = cht["categories"]
|
|
175
|
+
for name, values in cht["series"].items():
|
|
176
|
+
data.add_series(name, values)
|
|
177
|
+
frame = slide.shapes.add_chart(
|
|
178
|
+
CHART_TYPES.get(cht.get("type", "bar"),
|
|
179
|
+
XL_CHART_TYPE.COLUMN_CLUSTERED),
|
|
180
|
+
Inches(cht.get("left", 1)), Inches(cht.get("top", 2)),
|
|
181
|
+
Inches(cht.get("width", 6)), Inches(cht.get("height", 4)), data)
|
|
182
|
+
if cht.get("title"):
|
|
183
|
+
frame.chart.has_title = True
|
|
184
|
+
frame.chart.chart_title.text_frame.text = cht["title"]
|
|
185
|
+
|
|
186
|
+
if spec.get("notes"):
|
|
187
|
+
slide.notes_slide.notes_text_frame.text = spec["notes"]
|
|
188
|
+
return slide
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv=None):
|
|
192
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
193
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
194
|
+
parser = argparse.ArgumentParser(
|
|
195
|
+
description="Create a .pptx deck from a JSON spec.",
|
|
196
|
+
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
197
|
+
parser.add_argument("spec", help="path to JSON deck spec")
|
|
198
|
+
parser.add_argument("output", help="output .pptx path")
|
|
199
|
+
args = parser.parse_args(argv)
|
|
200
|
+
|
|
201
|
+
with open(args.spec, encoding="utf-8") as fh:
|
|
202
|
+
spec = json.load(fh)
|
|
203
|
+
|
|
204
|
+
prs = Presentation()
|
|
205
|
+
if spec.get("slide_size", "16:9") == "16:9":
|
|
206
|
+
old_w = prs.slide_width
|
|
207
|
+
prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
|
|
208
|
+
if not getattr(args, "template", None) and old_w and old_w != prs.slide_width:
|
|
209
|
+
# python-pptx's built-in template is 4:3: stretch its master/layout placeholders
|
|
210
|
+
# horizontally, otherwise every slide hugs the left three quarters.
|
|
211
|
+
k = prs.slide_width / old_w
|
|
212
|
+
for master in prs.slide_masters:
|
|
213
|
+
for shp in list(master.shapes) + [s for lay in master.slide_layouts for s in lay.shapes]:
|
|
214
|
+
# only shapes with their own <a:xfrm>; inherited positions follow the master
|
|
215
|
+
xfrm = shp._element.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}xfrm")
|
|
216
|
+
off = xfrm.find("{http://schemas.openxmlformats.org/drawingml/2006/main}off") if xfrm is not None else None
|
|
217
|
+
ext = xfrm.find("{http://schemas.openxmlformats.org/drawingml/2006/main}ext") if xfrm is not None else None
|
|
218
|
+
if off is not None and ext is not None:
|
|
219
|
+
off.set("x", str(int(int(off.get("x")) * k)))
|
|
220
|
+
ext.set("cx", str(int(int(ext.get("cx")) * k)))
|
|
221
|
+
else:
|
|
222
|
+
prs.slide_width, prs.slide_height = Inches(10), Inches(7.5)
|
|
223
|
+
|
|
224
|
+
for slide_spec in spec.get("slides", []):
|
|
225
|
+
build_slide(prs, slide_spec)
|
|
226
|
+
|
|
227
|
+
prs.save(args.output)
|
|
228
|
+
cjk = None
|
|
229
|
+
if spec.get("cjk_fonts", "auto") and re.search(r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uff00-\uffef]",
|
|
230
|
+
json.dumps(spec, ensure_ascii=False)):
|
|
231
|
+
from cjk_fonts import process as set_cjk
|
|
232
|
+
f = spec.get("cjk_fonts") if isinstance(spec.get("cjk_fonts"), dict) else {}
|
|
233
|
+
cjk = set_cjk(args.output, args.output, f.get("body"), f.get("heading"), f.get("lang", "zh-CN"))
|
|
234
|
+
print(json.dumps({"ok": True, "cjk_fonts": cjk and cjk["body"], "output": args.output,
|
|
235
|
+
"slides": len(prs.slides._sldIdLst)}, ensure_ascii=False))
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
sys.exit(main())
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Edit a .pptx in place (or save to --output).
|
|
3
|
+
|
|
4
|
+
Operations (repeatable / combinable):
|
|
5
|
+
--replace-text OLD NEW Replace text everywhere (slides, tables, notes).
|
|
6
|
+
Adjacent runs with identical formatting are
|
|
7
|
+
merged first, so matches PowerPoint split across
|
|
8
|
+
identically-formatted runs keep their formatting.
|
|
9
|
+
Only a match spanning genuinely different
|
|
10
|
+
formats falls back to a paragraph rewrite with
|
|
11
|
+
the first run's font (documented caveat).
|
|
12
|
+
--chart-data SPEC.json Update a chart. Full replace spec:
|
|
13
|
+
{"slide": 0, "chart": 0,
|
|
14
|
+
"categories": ["Q1", "Q2"],
|
|
15
|
+
"series": {"North": [1, 2], "South": [3, 4]}}
|
|
16
|
+
Or surgical ops (existing data is read, modified,
|
|
17
|
+
and written back via replace_data):
|
|
18
|
+
{"slide": 0, "chart": 0, "ops": [
|
|
19
|
+
{"op": "update_series", "name": "North",
|
|
20
|
+
"values": [5, 6]},
|
|
21
|
+
{"op": "add_series", "name": "East",
|
|
22
|
+
"values": [1, 2]},
|
|
23
|
+
{"op": "remove_series", "name": "South"},
|
|
24
|
+
{"op": "rename_category", "from": "Q1",
|
|
25
|
+
"to": "Q1 FY26"},
|
|
26
|
+
{"op": "set_title", "title": "New title"}]}
|
|
27
|
+
--swap-image SLIDE SHAPE_NAME NEW_IMAGE
|
|
28
|
+
Replace a picture's bits, keeping position/size.
|
|
29
|
+
--remove-slide N Delete slide at index N (0-based).
|
|
30
|
+
--move-slide FROM TO Reorder: move slide FROM to position TO.
|
|
31
|
+
--duplicate-slide N Append an independent deep copy of slide N
|
|
32
|
+
(text, images, tables, shapes, notes). Refuses
|
|
33
|
+
slides containing charts (a chart embeds an XLSX
|
|
34
|
+
workbook part that cannot be cloned reliably).
|
|
35
|
+
--set-background N HEX Solid background color for slide N.
|
|
36
|
+
--hyperlink N TEXT URL Make runs containing TEXT on slide N links.
|
|
37
|
+
--enable-slide-number N Copy the layout's slide-number placeholder in.
|
|
38
|
+
--set-footer N TEXT Enable the layout's footer placeholder with TEXT.
|
|
39
|
+
--set-notes N TEXT Replace slide N's speaker notes.
|
|
40
|
+
--append-notes N TEXT Append a paragraph to slide N's speaker notes.
|
|
41
|
+
"""
|
|
42
|
+
import argparse
|
|
43
|
+
import copy
|
|
44
|
+
import json
|
|
45
|
+
import sys
|
|
46
|
+
|
|
47
|
+
from lxml import etree
|
|
48
|
+
from pptx import Presentation
|
|
49
|
+
from pptx.chart.data import CategoryChartData
|
|
50
|
+
from pptx.dml.color import RGBColor
|
|
51
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
52
|
+
from pptx.oxml.ns import qn
|
|
53
|
+
|
|
54
|
+
R_EMBED = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_format_key(r):
|
|
58
|
+
"""Canonical string for a run's <a:rPr>; None when absent."""
|
|
59
|
+
rPr = r.find(qn("a:rPr"))
|
|
60
|
+
if rPr is None:
|
|
61
|
+
return None
|
|
62
|
+
return etree.tostring(rPr)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_runs(para):
|
|
66
|
+
"""Merge adjacent runs whose formatting is byte-identical.
|
|
67
|
+
|
|
68
|
+
PowerPoint splits paragraph text into runs at spell-check and edit
|
|
69
|
+
boundaries even when formatting never changes; merging them back makes
|
|
70
|
+
cross-run text replacement lossless for the common case.
|
|
71
|
+
"""
|
|
72
|
+
runs = list(para.runs)
|
|
73
|
+
i = 0
|
|
74
|
+
while i + 1 < len(runs):
|
|
75
|
+
a, b = runs[i], runs[i + 1]
|
|
76
|
+
if (_run_format_key(a._r) == _run_format_key(b._r)
|
|
77
|
+
and a._r.getnext() is b._r):
|
|
78
|
+
a.text = a.text + b.text
|
|
79
|
+
b._r.getparent().remove(b._r)
|
|
80
|
+
runs.pop(i + 1)
|
|
81
|
+
else:
|
|
82
|
+
i += 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def replace_in_text_frame(text_frame, old, new):
|
|
86
|
+
count = 0
|
|
87
|
+
for para in text_frame.paragraphs:
|
|
88
|
+
joined = "".join(run.text for run in para.runs)
|
|
89
|
+
if old not in joined:
|
|
90
|
+
continue
|
|
91
|
+
if not any(old in run.text for run in para.runs):
|
|
92
|
+
# Match spans runs: merge identically-formatted neighbours
|
|
93
|
+
# first, which resolves pure spell-check splits losslessly.
|
|
94
|
+
normalize_runs(para)
|
|
95
|
+
if any(old in run.text for run in para.runs):
|
|
96
|
+
# Run-level replace: preserves each run's formatting exactly.
|
|
97
|
+
for run in para.runs:
|
|
98
|
+
if old in run.text:
|
|
99
|
+
count += run.text.count(old)
|
|
100
|
+
run.text = run.text.replace(old, new)
|
|
101
|
+
else:
|
|
102
|
+
# Match spans genuinely differently-formatted runs -> rewrite
|
|
103
|
+
# paragraph, keeping only the first run's formatting (caveat).
|
|
104
|
+
joined = "".join(run.text for run in para.runs)
|
|
105
|
+
count += joined.count(old)
|
|
106
|
+
first = para.runs[0]
|
|
107
|
+
first.text = joined.replace(old, new)
|
|
108
|
+
for run in para.runs[1:]:
|
|
109
|
+
run._r.getparent().remove(run._r)
|
|
110
|
+
return count
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def iter_text_frames(slide):
|
|
114
|
+
for shape in slide.shapes:
|
|
115
|
+
if shape.has_text_frame:
|
|
116
|
+
yield shape.text_frame
|
|
117
|
+
if shape.has_table:
|
|
118
|
+
for row in shape.table.rows:
|
|
119
|
+
for cell in row.cells:
|
|
120
|
+
yield cell.text_frame
|
|
121
|
+
if slide.has_notes_slide:
|
|
122
|
+
yield slide.notes_slide.notes_text_frame
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def replace_text(prs, old, new):
|
|
126
|
+
total = 0
|
|
127
|
+
for slide in prs.slides:
|
|
128
|
+
for tf in iter_text_frames(slide):
|
|
129
|
+
total += replace_in_text_frame(tf, old, new)
|
|
130
|
+
return total
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_chart_data(chart):
|
|
134
|
+
"""Current categories and ordered (name, values) pairs of a chart."""
|
|
135
|
+
categories = [str(c) for c in chart.plots[0].categories]
|
|
136
|
+
series = []
|
|
137
|
+
for plot in chart.plots:
|
|
138
|
+
for s in plot.series:
|
|
139
|
+
try:
|
|
140
|
+
name = s.name
|
|
141
|
+
except (AttributeError, KeyError):
|
|
142
|
+
name = ""
|
|
143
|
+
series.append([name, list(s.values)])
|
|
144
|
+
return categories, series
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def update_chart(prs, spec_path):
|
|
148
|
+
"""Full replace ("categories"+"series") or surgical "ops".
|
|
149
|
+
|
|
150
|
+
python-pptx can only swap a chart's entire dataset (replace_data), so
|
|
151
|
+
surgical ops are implemented as read-existing -> modify -> replace.
|
|
152
|
+
"""
|
|
153
|
+
with open(spec_path, encoding="utf-8") as fh:
|
|
154
|
+
spec = json.load(fh)
|
|
155
|
+
slide = prs.slides[spec.get("slide", 0)]
|
|
156
|
+
charts = [s.chart for s in slide.shapes if s.has_chart]
|
|
157
|
+
if not charts:
|
|
158
|
+
raise SystemExit(f"no chart on slide {spec.get('slide', 0)}")
|
|
159
|
+
chart = charts[spec.get("chart", 0)]
|
|
160
|
+
|
|
161
|
+
if "ops" in spec:
|
|
162
|
+
categories, series = _read_chart_data(chart)
|
|
163
|
+
dirty = False
|
|
164
|
+
for op in spec["ops"]:
|
|
165
|
+
kind = op["op"]
|
|
166
|
+
if kind == "update_series":
|
|
167
|
+
match = [s for s in series if s[0] == op["name"]]
|
|
168
|
+
if not match:
|
|
169
|
+
raise SystemExit(f"no series named {op['name']!r}")
|
|
170
|
+
match[0][1] = op["values"]
|
|
171
|
+
dirty = True
|
|
172
|
+
elif kind == "add_series":
|
|
173
|
+
series.append([op["name"], op["values"]])
|
|
174
|
+
dirty = True
|
|
175
|
+
elif kind == "remove_series":
|
|
176
|
+
before = len(series)
|
|
177
|
+
series = [s for s in series if s[0] != op["name"]]
|
|
178
|
+
if len(series) == before:
|
|
179
|
+
raise SystemExit(f"no series named {op['name']!r}")
|
|
180
|
+
dirty = True
|
|
181
|
+
elif kind == "rename_category":
|
|
182
|
+
if "index" in op:
|
|
183
|
+
idx = int(op["index"])
|
|
184
|
+
else:
|
|
185
|
+
if op["from"] not in categories:
|
|
186
|
+
raise SystemExit(
|
|
187
|
+
f"no category named {op['from']!r}")
|
|
188
|
+
idx = categories.index(op["from"])
|
|
189
|
+
categories[idx] = op["to"]
|
|
190
|
+
dirty = True
|
|
191
|
+
elif kind == "set_title":
|
|
192
|
+
chart.has_title = True
|
|
193
|
+
chart.chart_title.text_frame.text = op["title"]
|
|
194
|
+
else:
|
|
195
|
+
raise SystemExit(f"unknown chart op {kind!r}")
|
|
196
|
+
if dirty:
|
|
197
|
+
data = CategoryChartData()
|
|
198
|
+
data.categories = categories
|
|
199
|
+
for name, values in series:
|
|
200
|
+
data.add_series(name, values)
|
|
201
|
+
chart.replace_data(data)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
data = CategoryChartData()
|
|
205
|
+
data.categories = spec["categories"]
|
|
206
|
+
for name, values in spec["series"].items():
|
|
207
|
+
data.add_series(name, values)
|
|
208
|
+
chart.replace_data(data)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def swap_image(prs, slide_idx, shape_name, new_path):
|
|
212
|
+
slide = prs.slides[int(slide_idx)]
|
|
213
|
+
for shape in slide.shapes:
|
|
214
|
+
if (shape.shape_type == MSO_SHAPE_TYPE.PICTURE
|
|
215
|
+
and shape.name == shape_name):
|
|
216
|
+
_image_part, rid = slide.part.get_or_add_image_part(new_path)
|
|
217
|
+
blip = shape._element.blipFill.blip
|
|
218
|
+
blip.set(R_EMBED + "embed", rid)
|
|
219
|
+
return True
|
|
220
|
+
raise SystemExit(f"no picture named {shape_name!r} on slide {slide_idx}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def remove_slide(prs, index):
|
|
224
|
+
sldIdLst = prs.slides._sldIdLst
|
|
225
|
+
slide_id = list(sldIdLst)[int(index)]
|
|
226
|
+
rid = slide_id.get(R_EMBED + "id")
|
|
227
|
+
prs.part.drop_rel(rid)
|
|
228
|
+
sldIdLst.remove(slide_id)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def move_slide(prs, src, dst):
|
|
232
|
+
"""Reorder by moving the <p:sldId> element inside <p:sldIdLst>."""
|
|
233
|
+
sldIdLst = prs.slides._sldIdLst
|
|
234
|
+
ids = list(sldIdLst)
|
|
235
|
+
element = ids[int(src)]
|
|
236
|
+
sldIdLst.remove(element)
|
|
237
|
+
sldIdLst.insert(int(dst), element)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def duplicate_slide(prs, index):
|
|
241
|
+
"""Append an independent deep copy of slide `index`.
|
|
242
|
+
|
|
243
|
+
Copies the shape tree XML and re-creates image/media relationships on
|
|
244
|
+
the new slide part, remapping rIds. Charts are refused: each chart
|
|
245
|
+
relationship embeds a separate XLSX workbook part, and cloning that
|
|
246
|
+
graph reliably is not supported — better to refuse than corrupt.
|
|
247
|
+
"""
|
|
248
|
+
source = prs.slides[int(index)]
|
|
249
|
+
if any(sh.has_chart for sh in source.shapes):
|
|
250
|
+
raise SystemExit(
|
|
251
|
+
f"slide {index} contains a chart; duplication of chart slides "
|
|
252
|
+
"is not supported (chart XML embeds a workbook part that "
|
|
253
|
+
"cannot be cloned safely). Rebuild the chart on a new slide "
|
|
254
|
+
"with pptx_create.py / pptx_from_template.py instead.")
|
|
255
|
+
|
|
256
|
+
dest = prs.slides.add_slide(source.slide_layout)
|
|
257
|
+
# drop the placeholders add_slide seeded from the layout
|
|
258
|
+
for shape in list(dest.shapes):
|
|
259
|
+
shape._element.getparent().remove(shape._element)
|
|
260
|
+
|
|
261
|
+
for shape in source.shapes:
|
|
262
|
+
dest.shapes._spTree.append(copy.deepcopy(shape._element))
|
|
263
|
+
|
|
264
|
+
# re-create the source slide's part relationships on the copy
|
|
265
|
+
rid_map = {}
|
|
266
|
+
for rel in list(source.part.rels.values()):
|
|
267
|
+
if rel.reltype.endswith(("/slideLayout", "/notesSlide")):
|
|
268
|
+
continue
|
|
269
|
+
if rel.is_external:
|
|
270
|
+
new_rid = dest.part.rels.get_or_add_ext_rel(
|
|
271
|
+
rel.reltype, rel.target_ref)
|
|
272
|
+
else:
|
|
273
|
+
new_rid = dest.part.relate_to(rel.target_part, rel.reltype)
|
|
274
|
+
rid_map[rel.rId] = new_rid
|
|
275
|
+
|
|
276
|
+
for el in dest.shapes._spTree.iter():
|
|
277
|
+
for attr, val in el.attrib.items():
|
|
278
|
+
if attr.startswith(R_EMBED) and val in rid_map:
|
|
279
|
+
el.set(attr, rid_map[val])
|
|
280
|
+
|
|
281
|
+
if source.has_notes_slide:
|
|
282
|
+
dest.notes_slide.notes_text_frame.text = (
|
|
283
|
+
source.notes_slide.notes_text_frame.text)
|
|
284
|
+
return len(prs.slides._sldIdLst) - 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def set_background(slide, hex_color):
|
|
288
|
+
fill = slide.background.fill
|
|
289
|
+
fill.solid()
|
|
290
|
+
fill.fore_color.rgb = RGBColor.from_string(hex_color)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def add_hyperlink(prs, slide_idx, text, url):
|
|
294
|
+
"""Turn every run containing `text` on the slide into a hyperlink.
|
|
295
|
+
|
|
296
|
+
The link applies to the whole run (python-pptx links whole runs).
|
|
297
|
+
"""
|
|
298
|
+
slide = prs.slides[int(slide_idx)]
|
|
299
|
+
hits = 0
|
|
300
|
+
for shape in slide.shapes:
|
|
301
|
+
if not shape.has_text_frame:
|
|
302
|
+
continue
|
|
303
|
+
for para in shape.text_frame.paragraphs:
|
|
304
|
+
for run in para.runs:
|
|
305
|
+
if text in run.text:
|
|
306
|
+
run.hyperlink.address = url
|
|
307
|
+
hits += 1
|
|
308
|
+
if not hits:
|
|
309
|
+
raise SystemExit(f"no run containing {text!r} on slide {slide_idx}")
|
|
310
|
+
return hits
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _copy_layout_placeholder(slide, ph_idx):
|
|
314
|
+
"""Copy the layout placeholder with idx `ph_idx` onto the slide.
|
|
315
|
+
|
|
316
|
+
Slide-number (idx 12) and footer (idx 11) placeholders exist on the
|
|
317
|
+
layout but are not inherited by a slide until the slide carries its
|
|
318
|
+
own copy — this enables them. Returns the new shape, or None when the
|
|
319
|
+
layout does not provide that placeholder.
|
|
320
|
+
"""
|
|
321
|
+
for ph in slide.slide_layout.placeholders:
|
|
322
|
+
if ph.placeholder_format.idx == ph_idx:
|
|
323
|
+
el = copy.deepcopy(ph._element)
|
|
324
|
+
slide.shapes._spTree.append(el)
|
|
325
|
+
for shape in slide.placeholders:
|
|
326
|
+
if shape.placeholder_format.idx == ph_idx:
|
|
327
|
+
return shape
|
|
328
|
+
return None
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def enable_slide_number(slide):
|
|
333
|
+
if any(ph.placeholder_format.idx == 12 for ph in slide.placeholders):
|
|
334
|
+
return True
|
|
335
|
+
return _copy_layout_placeholder(slide, 12) is not None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def set_footer(slide, text):
|
|
339
|
+
shape = next((ph for ph in slide.placeholders
|
|
340
|
+
if ph.placeholder_format.idx == 11), None)
|
|
341
|
+
if shape is None:
|
|
342
|
+
shape = _copy_layout_placeholder(slide, 11)
|
|
343
|
+
if shape is None:
|
|
344
|
+
raise SystemExit("layout provides no footer placeholder; add a "
|
|
345
|
+
"textbox instead")
|
|
346
|
+
shape.text_frame.text = text
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def set_notes(slide, text, append=False):
|
|
351
|
+
tf = slide.notes_slide.notes_text_frame
|
|
352
|
+
if append and tf.text:
|
|
353
|
+
para = tf.add_paragraph()
|
|
354
|
+
para.text = text
|
|
355
|
+
else:
|
|
356
|
+
tf.text = text
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def main(argv=None):
|
|
360
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
361
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
362
|
+
parser = argparse.ArgumentParser(
|
|
363
|
+
description="Edit a .pptx: replace text, update chart data, swap "
|
|
364
|
+
"images, duplicate/remove/reorder slides, backgrounds, "
|
|
365
|
+
"hyperlinks, footers, slide numbers, speaker notes.",
|
|
366
|
+
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
367
|
+
parser.add_argument("pptx", help="path to the .pptx file")
|
|
368
|
+
parser.add_argument("--output", help="save to this path instead of "
|
|
369
|
+
"overwriting the input")
|
|
370
|
+
parser.add_argument("--replace-text", nargs=2, action="append",
|
|
371
|
+
metavar=("OLD", "NEW"), default=[])
|
|
372
|
+
parser.add_argument("--chart-data", metavar="SPEC_JSON")
|
|
373
|
+
parser.add_argument("--swap-image", nargs=3,
|
|
374
|
+
metavar=("SLIDE", "SHAPE_NAME", "IMAGE"))
|
|
375
|
+
parser.add_argument("--remove-slide", type=int, metavar="N")
|
|
376
|
+
parser.add_argument("--move-slide", nargs=2, type=int,
|
|
377
|
+
metavar=("FROM", "TO"))
|
|
378
|
+
parser.add_argument("--duplicate-slide", type=int, metavar="N")
|
|
379
|
+
parser.add_argument("--set-background", nargs=2,
|
|
380
|
+
metavar=("SLIDE", "HEX"))
|
|
381
|
+
parser.add_argument("--hyperlink", nargs=3,
|
|
382
|
+
metavar=("SLIDE", "TEXT", "URL"))
|
|
383
|
+
parser.add_argument("--enable-slide-number", type=int, metavar="N")
|
|
384
|
+
parser.add_argument("--set-footer", nargs=2, metavar=("SLIDE", "TEXT"))
|
|
385
|
+
parser.add_argument("--set-notes", nargs=2, metavar=("SLIDE", "TEXT"))
|
|
386
|
+
parser.add_argument("--append-notes", nargs=2, metavar=("SLIDE", "TEXT"))
|
|
387
|
+
args = parser.parse_args(argv)
|
|
388
|
+
|
|
389
|
+
prs = Presentation(args.pptx)
|
|
390
|
+
report = {"ok": True, "replacements": 0}
|
|
391
|
+
|
|
392
|
+
for old, new in args.replace_text:
|
|
393
|
+
report["replacements"] += replace_text(prs, old, new)
|
|
394
|
+
if args.chart_data:
|
|
395
|
+
update_chart(prs, args.chart_data)
|
|
396
|
+
report["chart_updated"] = True
|
|
397
|
+
if args.swap_image:
|
|
398
|
+
swap_image(prs, *args.swap_image)
|
|
399
|
+
report["image_swapped"] = True
|
|
400
|
+
if args.duplicate_slide is not None:
|
|
401
|
+
report["duplicated_to"] = duplicate_slide(prs, args.duplicate_slide)
|
|
402
|
+
if args.set_background:
|
|
403
|
+
set_background(prs.slides[int(args.set_background[0])],
|
|
404
|
+
args.set_background[1])
|
|
405
|
+
report["background_set"] = True
|
|
406
|
+
if args.hyperlink:
|
|
407
|
+
report["hyperlinked_runs"] = add_hyperlink(prs, *args.hyperlink)
|
|
408
|
+
if args.enable_slide_number is not None:
|
|
409
|
+
report["slide_number_enabled"] = enable_slide_number(
|
|
410
|
+
prs.slides[args.enable_slide_number])
|
|
411
|
+
if args.set_footer:
|
|
412
|
+
set_footer(prs.slides[int(args.set_footer[0])], args.set_footer[1])
|
|
413
|
+
report["footer_set"] = True
|
|
414
|
+
if args.set_notes:
|
|
415
|
+
set_notes(prs.slides[int(args.set_notes[0])], args.set_notes[1])
|
|
416
|
+
report["notes_set"] = True
|
|
417
|
+
if args.append_notes:
|
|
418
|
+
set_notes(prs.slides[int(args.append_notes[0])],
|
|
419
|
+
args.append_notes[1], append=True)
|
|
420
|
+
report["notes_appended"] = True
|
|
421
|
+
if args.remove_slide is not None:
|
|
422
|
+
remove_slide(prs, args.remove_slide)
|
|
423
|
+
report["slide_removed"] = args.remove_slide
|
|
424
|
+
if args.move_slide:
|
|
425
|
+
move_slide(prs, *args.move_slide)
|
|
426
|
+
report["slide_moved"] = args.move_slide
|
|
427
|
+
|
|
428
|
+
out = args.output or args.pptx
|
|
429
|
+
prs.save(out)
|
|
430
|
+
report["output"] = out
|
|
431
|
+
print(json.dumps(report))
|
|
432
|
+
return 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == "__main__":
|
|
436
|
+
sys.exit(main())
|