scripts/pptx_edit.py
scripts/pptx_edit.pyBrowse 8 files
3,941 tokens
17,224 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Edit a .pptx in place (or save to --output).3 4Operations (repeatable / combinable):5 --replace-text OLD NEW Replace text everywhere (slides, tables, notes).6 Adjacent runs with identical formatting are7 merged first, so matches PowerPoint split across8 identically-formatted runs keep their formatting.9 Only a match spanning genuinely different10 formats falls back to a paragraph rewrite with11 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_IMAGE28 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 N32 (text, images, tables, shapes, notes). Refuses33 slides containing charts (a chart embeds an XLSX34 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"""42import argparse43import copy44import json45import sys46 47from lxml import etree48from pptx import Presentation49from pptx.chart.data import CategoryChartData50from pptx.dml.color import RGBColor51from pptx.enum.shapes import MSO_SHAPE_TYPE52from pptx.oxml.ns import qn53 54R_EMBED = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"55 56 57def _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 None62 return etree.tostring(rPr)63 64 65def normalize_runs(para):66 """Merge adjacent runs whose formatting is byte-identical.67 68 PowerPoint splits paragraph text into runs at spell-check and edit69 boundaries even when formatting never changes; merging them back makes70 cross-run text replacement lossless for the common case.71 """72 runs = list(para.runs)73 i = 074 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.text79 b._r.getparent().remove(b._r)80 runs.pop(i + 1)81 else:82 i += 183 84 85def replace_in_text_frame(text_frame, old, new):86 count = 087 for para in text_frame.paragraphs:88 joined = "".join(run.text for run in para.runs)89 if old not in joined:90 continue91 if not any(old in run.text for run in para.runs):92 # Match spans runs: merge identically-formatted neighbours93 # 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 -> rewrite103 # 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 count111 112 113def iter_text_frames(slide):114 for shape in slide.shapes:115 if shape.has_text_frame:116 yield shape.text_frame117 if shape.has_table:118 for row in shape.table.rows:119 for cell in row.cells:120 yield cell.text_frame121 if slide.has_notes_slide:122 yield slide.notes_slide.notes_text_frame123 124 125def replace_text(prs, old, new):126 total = 0127 for slide in prs.slides:128 for tf in iter_text_frames(slide):129 total += replace_in_text_frame(tf, old, new)130 return total131 132 133def _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.name141 except (AttributeError, KeyError):142 name = ""143 series.append([name, list(s.values)])144 return categories, series145 146 147def 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), so151 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 = False164 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 = True172 elif kind == "add_series":173 series.append([op["name"], op["values"]])174 dirty = True175 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 = True181 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 = True191 elif kind == "set_title":192 chart.has_title = True193 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 = categories199 for name, values in series:200 data.add_series(name, values)201 chart.replace_data(data)202 return203 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 211def 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.PICTURE215 and shape.name == shape_name):216 image_part, rid = slide.part.get_or_add_image_part(new_path)217 blip = shape._element.blipFill.blip218 blip.set(R_EMBED + "embed", rid)219 return True220 raise SystemExit(f"no picture named {shape_name!r} on slide {slide_idx}")221 222 223def remove_slide(prs, index):224 sldIdLst = prs.slides._sldIdLst225 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 231def move_slide(prs, src, dst):232 """Reorder by moving the <p:sldId> element inside <p:sldIdLst>."""233 sldIdLst = prs.slides._sldIdLst234 ids = list(sldIdLst)235 element = ids[int(src)]236 sldIdLst.remove(element)237 sldIdLst.insert(int(dst), element)238 239 240def 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 on244 the new slide part, remapping rIds. Charts are refused: each chart245 relationship embeds a separate XLSX workbook part, and cloning that246 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 layout258 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 copy265 rid_map = {}266 for rel in list(source.part.rels.values()):267 if rel.reltype.endswith(("/slideLayout", "/notesSlide")):268 continue269 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_rid275 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) - 1285 286 287def set_background(slide, hex_color):288 fill = slide.background.fill289 fill.solid()290 fill.fore_color.rgb = RGBColor.from_string(hex_color)291 292 293def 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 = 0300 for shape in slide.shapes:301 if not shape.has_text_frame:302 continue303 for para in shape.text_frame.paragraphs:304 for run in para.runs:305 if text in run.text:306 run.hyperlink.address = url307 hits += 1308 if not hits:309 raise SystemExit(f"no run containing {text!r} on slide {slide_idx}")310 return hits311 312 313def _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 the317 layout but are not inherited by a slide until the slide carries its318 own copy — this enables them. Returns the new shape, or None when the319 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 shape328 return None329 return None330 331 332def enable_slide_number(slide):333 if any(ph.placeholder_format.idx == 12 for ph in slide.placeholders):334 return True335 return _copy_layout_placeholder(slide, 12) is not None336 337 338def set_footer(slide, text):339 shape = next((ph for ph in slide.placeholders340 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 = text347 return True348 349 350def set_notes(slide, text, append=False):351 tf = slide.notes_slide.notes_text_frame352 if append and tf.text:353 para = tf.add_paragraph()354 para.text = text355 else:356 tf.text = text357 358 359def 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"] = True397 if args.swap_image:398 swap_image(prs, *args.swap_image)399 report["image_swapped"] = True400 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"] = True406 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"] = True414 if args.set_notes:415 set_notes(prs.slides[int(args.set_notes[0])], args.set_notes[1])416 report["notes_set"] = True417 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"] = True421 if args.remove_slide is not None:422 remove_slide(prs, args.remove_slide)423 report["slide_removed"] = args.remove_slide424 if args.move_slide:425 move_slide(prs, *args.move_slide)426 report["slide_moved"] = args.move_slide427 428 out = args.output or args.pptx429 prs.save(out)430 report["output"] = out431 print(json.dumps(report))432 return 0433 434 435if __name__ == "__main__":436 sys.exit(main())437