# -*- coding: utf-8 -*- """plank_deck_edit.py — port a deck the customer already has. `plank_deck.py` builds a deck in Plank's house style, on blank slides. This file is the other job: the customer hands you *their* deck and asks for the same thing for a new client. There the design is not yours to choose — it is the asset — and the only correct move is to open their file and change what the new client needs. curl -o plank_deck_edit.py https://plank.md/help/assets/plank_deck_edit.py from plank_deck_edit import * prs = open_deck("their-press-deck.pptx") print(inventory(prs)) # look before you touch keep_slides(prs, [0, 1, 2, 5, 9, 12, 15]) # a press deck carries extra replace_text(prs, { "орбитекс форте": "halyk bank", "orbitex ca": "halyk bank", }) swap_picture(prs.slides[3], "portraits/viktor.jpg") save(prs, "halyk-proposal.pptx") Why this exists at all: rebuilding is easy and editing was awkward, so rebuilding won. python-pptx has no API for deleting a slide, duplicating one, or retargeting text without losing its formatting, and a deck rebuilt from constants read off the customer's file loses everything nobody thought to retype. Measured on the deck this was written for: the picture count went from 38 to 6, Arial became Carlito, and a 36-run italic display convention vanished — because nothing about "dark deck, lime accent" tells you the display type is italic. Editing the same file reproduced its slide 2 to the hundredth of an inch. Nothing here draws anything. It moves, removes and rewrites what is already in the file, so colours, sizes, italics, fonts, masters, imagery and the customer's chrome survive by construction rather than by being remembered. A slide whose MESSAGE changes is rewritten through, not patched — headline and body in one call, so no old line survives under a new headline: rewrite_slide(prs, 4, ["Заявки и продажи", "Клиент написал — заявка уже в работе", ...]) Then compare the result with the original, slide by slide. This must exit 0 before the deck is handed over: python3 plank_deck_edit.py diff their-press-deck.pptx halyk-proposal.pptx It exits 1 on a half-edited slide (new headline, old lines underneath), when more than 4 of the person's slides changed or more than 2 were added — the same contract as plank_html_edit.py, for the same reason: on 2026-10-05 an agent read those findings as warnings and shipped anyway, and with an exit code it did not (evals/fixtures/e14-deck-adapt). `--max-changed N` / `--max-added N` only when the person asked for that much; `--keep-body N --because ""` only for a slide they asked to touch lightly. The QA tool's port mode compares the two files' aggregates (fonts, colours, pictures) — run it too: python3 plank_deck_qa.py halyk-proposal.pptx --source their-press-deck.pptx Requires python-pptx, which is preinstalled in every Plank workspace. """ import copy import os import re import sys import unicodedata from difflib import SequenceMatcher from pptx import Presentation from pptx.oxml.ns import qn class EditError(ValueError): """The edit does not describe something in this deck. Look, then edit.""" def open_deck(path): """The customer's file, opened for editing.""" if not os.path.isfile(path): raise EditError( "no deck at %r. A port starts from their file - confirm the path " "before you start, rather than building a new deck because the " "old one would not open." % (path,) ) return Presentation(path) def save(prs, path): prs.save(path) return path # --- look before you touch ------------------------------------------------- # # The single most expensive mistake in a port is deciding what a slide is # without looking at it: a run that replaced three portraits by filename order # put every one on the wrong person. `inventory` exists so that the plan is # made against what is actually in the file - shape indexes, positions, the # text as it really reads - and so that the reply can say which slide each # change landed on. def _preview(shape, limit=60): if not shape.has_text_frame: return "" text = " ".join(shape.text_frame.text.split()) return text[:limit] + ("…" if len(text) > limit else "") def _kind(shape): kind = str(shape.shape_type or "") if kind.startswith("PICTURE"): return "picture" if shape.has_text_frame and shape.text_frame.text.strip(): return "text" if kind.startswith("GROUP"): return "group" return "shape" def inventory(prs, slide=None): """Every slide, every shape, as a string you can read and plan against.""" lines = [] numbers = range(len(prs.slides)) if slide is None else [slide] for n in numbers: s = prs.slides[n] lines.append("slide %d — %d shapes" % (n, len(s.shapes))) for i, shape in enumerate(s.shapes): pos = "" if shape.top is not None and shape.left is not None: pos = "x=%.2f y=%.2f " % (shape.left / 914400.0, shape.top / 914400.0) lines.append(" [%d] %-7s %s%s" % (i, _kind(shape), pos, _preview(shape))) return "\n".join(lines) # --- slides ---------------------------------------------------------------- def _sldIdLst(prs): return prs.slides._sldIdLst def drop_slides(prs, indexes): """Remove slides by index. Indexes are read before anything moves. Drops the relationship as well as the entry in the slide list. Taking the entry alone leaves the slide's part in the package - invisible in the deck, still written to the file - and the next `duplicate_slide` then picks a part name that is already taken, producing a .pptx with two entries of the same name. PowerPoint may or may not survive that; nothing should ship it. """ ids = list(_sldIdLst(prs)) for i in sorted(set(indexes), reverse=True): if not 0 <= i < len(ids): raise EditError( "no slide %d - this deck has %d (0-%d)." % (i, len(ids), len(ids) - 1) ) element = ids[i] rId = element.rId _sldIdLst(prs).remove(element) prs.part.drop_rel(rId) return prs def keep_slides(prs, indexes): """Keep only these slides, in the deck's own order. The usual shape of a port: a press deck carries more than one brief needs, so you subtract. Stated as what survives, because that is the list you can check against the storyline. """ keep = set(indexes) total = len(_sldIdLst(prs)) unknown = [i for i in keep if not 0 <= i < total] if unknown: raise EditError( "no slide %s - this deck has %d (0-%d)." % (", ".join(str(i) for i in sorted(unknown)), total, total - 1) ) return drop_slides(prs, [i for i in range(total) if i not in keep]) def _free_slide_partname(prs): from pptx.opc.packuri import PackURI # noqa: PLC0415 taken = {part.partname for part in prs.part.package.iter_parts()} n = 1 while PackURI("/ppt/slides/slide%d.xml" % n) in taken: n += 1 return PackURI("/ppt/slides/slide%d.xml" % n) def duplicate_slide(prs, index): """Copy a slide to the end of the deck and return it. For the case the source cannot cover by subtraction - a fourth director where their deck had three. Copying their slide keeps their layout; a new blank one does not. """ src = prs.slides[index] dest = prs.slides.add_slide(src.slide_layout) # python-pptx names the new part "slide{count+1}.xml", which collides the # moment slides have been dropped and a higher-numbered part is still in # use - the saved .pptx then carries two zip entries of the same name. # Porting drops slides almost every time, so this is the common case, not # the corner one. dest.part.partname = _free_slide_partname(prs) for shape in list(dest.shapes): shape._element.getparent().remove(shape._element) for shape in src.shapes: dest.shapes._spTree.append(copy.deepcopy(shape._element)) # Pictures live in parts, not in the shape XML: without re-pointing the # relationships the copy renders as an empty frame, which is the one # failure mode a deck must never ship. for key, rel in src.part.rels.items(): if rel.is_external: dest.part.rels.get_or_add_ext_rel(rel.reltype, rel.target_ref) elif "image" in rel.reltype or "media" in rel.reltype: dest.part.rels.get_or_add(rel.reltype, rel.target_part) return dest def move_slide(prs, index, to): """Move a slide to position `to`, counted in the deck you end up with. `duplicate_slide` appends, and a copy of the second camera candidate needs to sit beside the first, not at the end after the closing slide. Without this the only way to order a port was to rebuild it, which is the thing this file exists to avoid. """ ids = list(_sldIdLst(prs)) if not 0 <= index < len(ids): raise EditError( "no slide %d - this deck has %d (0-%d)." % (index, len(ids), len(ids) - 1) ) if not 0 <= to < len(ids): raise EditError( "cannot move to %d - this deck has %d slides (0-%d)." % (to, len(ids), len(ids) - 1) ) element = ids[index] _sldIdLst(prs).remove(element) _sldIdLst(prs).insert(to, element) return prs # --- text ------------------------------------------------------------------ def _runs(scope): slides = None if hasattr(scope, "slides"): slides = list(scope.slides) elif hasattr(scope, "shapes"): slides = [scope] if slides is not None: for slide in slides: for shape in slide.shapes: if shape.has_text_frame: for para in shape.text_frame.paragraphs: for run in para.runs: yield run return if scope.has_text_frame: # a single shape for para in scope.text_frame.paragraphs: for run in para.runs: yield run def _paragraphs(scope): slides = None if hasattr(scope, "slides"): slides = list(scope.slides) elif hasattr(scope, "shapes"): slides = [scope] if slides is not None: for slide in slides: for shape in slide.shapes: if shape.has_text_frame: for para in shape.text_frame.paragraphs: yield para return if scope.has_text_frame: # a single shape for para in scope.text_frame.paragraphs: yield para def _replace_once(para, old, new): """One occurrence of `old`, however many runs it is spread across. PowerPoint fragments runs for reasons that have nothing to do with design - a spellcheck pass, a language tag, the order someone typed in - so a phrase the reader sees as one string is routinely three runs. Matching a run at a time only works on a tidy deck, and a deck that needed porting is rarely tidy. The replacement lands in the run where the match STARTS and inherits that run's formatting, which is the right answer: it is the value's own run, so a grey label followed by a white value stays grey then white. """ runs = para.runs if not runs: return False texts = [r.text or "" for r in runs] full = "".join(texts) at = full.find(old) if at < 0: return False end = at + len(old) starts = [] pos = 0 for text in texts: starts.append(pos) pos += len(text) first = last = None for i, text in enumerate(texts): lo, hi = starts[i], starts[i] + len(text) if first is None and lo <= at < hi: first = i if lo < end <= hi: last = i break if first is None: return False if last is None: last = len(texts) - 1 head = texts[first][:at - starts[first]] tail = texts[last][end - starts[last]:] if first == last: runs[first].text = head + new + tail else: runs[first].text = head + new for i in range(first + 1, last): runs[i].text = "" runs[last].text = tail return True def replace_text(scope, mapping, required=True): """Swap strings in the text, leaving the runs' formatting alone. `scope` is a deck, a slide or a shape. The match is made against the paragraph as it READS, so a phrase split across several runs is found; the replacement inherits the formatting of the run it starts in. That is what preserves the design without anyone having to know the rules: in a well-made deck the label and its value are already separate runs - "brand " in grey, "орбитекс форте" in white - so replacing the value keeps the grey label grey. Returns {old: count}. With `required`, a string that matched nothing is an error: silently changing nothing is how a port ships the previous client's name. """ hits = {old: 0 for old in mapping} for para in _paragraphs(scope): for old, new in mapping.items(): # Bounded: each pass removes one occurrence, and a replacement # containing its own search string would otherwise never end. for _ in range(1000): if not _replace_once(para, old, new): break hits[old] += 1 if old in new: break missed = [old for old, n in hits.items() if not n] if required and missed: raise EditError( "found nothing to replace for %s. Read `inventory(prs)` and match " "the text as it actually reads - including its spaces and its " "dashes, which are often not the ones you would type." % ", ".join(repr(m) for m in missed) ) return hits def set_text(shape, text): """Replace a shape's text, keeping the first run's formatting. For a line that is rewritten rather than edited - a title, a lead. Every run after the first goes, so use `replace_text` when the paragraph mixes formatting you need to keep. """ if not shape.has_text_frame: raise EditError("that shape holds no text") paras = shape.text_frame.paragraphs if not paras or not paras[0].runs: raise EditError( "that shape has no run to take formatting from. Copy a shape that " "does, or set the formatting explicitly." ) paras[0].runs[0].text = text for run in paras[0].runs[1:]: run._r.getparent().remove(run._r) for para in paras[1:]: para._p.getparent().remove(para._p) return shape def clear_links(scope): """Drop hyperlinks, keeping the text. Replacing the text of a link leaves the link: a rewritten bullet keeps the blue underline of the portfolio link it replaced, and still points at the previous client's showreel. `replace_text` cannot decide that for you - sometimes the link is exactly what should survive - so this is separate and deliberate. Returns how many it removed. """ gone = 0 for para in _paragraphs(scope): for run in para.runs: rPr = run._r.find(qn("a:rPr")) if rPr is None: continue for tag in ("a:hlinkClick", "a:hlinkMouseOver"): link = rPr.find(qn(tag)) if link is not None: rPr.remove(link) gone += 1 return gone # --- pictures -------------------------------------------------------------- def swap_picture(slide, image, index=0): """Replace a picture in place, keeping position, size and z-order. Not `add_picture` and a delete: a new picture lands on top of the stack and at its own size, which is how a portrait ends up over the text it was meant to sit behind. """ if not os.path.isfile(image): raise EditError( "no image at %r. The builder refuses a missing image rather than " "shipping an empty frame; so does this." % (image,) ) pictures = [sh for sh in slide.shapes if _kind(sh) == "picture"] if not pictures: raise EditError("this slide carries no picture to replace") if not 0 <= index < len(pictures): raise EditError( "no picture %d on this slide - it has %d (0-%d)." % (index, len(pictures), len(pictures) - 1) ) old = pictures[index] new = slide.shapes.add_picture(image, old.left, old.top, old.width, old.height) old._element.addnext(new._element) old._element.getparent().remove(old._element) return new # --- rewrite a slide through ---------------------------------------------- def _text_paragraphs(slide): """Every paragraph that carries text, in shape order (groups and tables too).""" out = [] def walk(shapes): for sh in shapes: if str(sh.shape_type or "").startswith("GROUP"): walk(sh.shapes) continue if sh.has_text_frame: out.extend(p for p in sh.text_frame.paragraphs if p.runs) if getattr(sh, "has_table", False) and sh.has_table: for row in sh.table.rows: for cell in row.cells: out.extend(p for p in cell.text_frame.paragraphs if p.runs) walk(slide.shapes) return out def rewrite_slide(prs, index, lines): """Replace every text paragraph of slide `index`, in order, with `lines`. Each paragraph keeps its FIRST run's formatting (font, size, colour, italics) and takes the new text; its other runs are emptied, and paragraphs beyond `lines` are emptied too. That is a slide rewritten through — the opposite of the half-edit the diff below refuses. """ paras = _text_paragraphs(prs.slides[index]) if len(lines) > len(paras): raise EditError( "slide %d has %d text paragraphs, you gave %d lines - duplicate a " "slide with more room, or shorten." % (index, len(paras), len(lines)) ) for k, para in enumerate(paras): runs = list(para.runs) runs[0].text = lines[k] if k < len(lines) else "" for r in runs[1:]: r.text = "" # --- did the edit change only what was asked? ------------------------------- def _norm(s): s = unicodedata.normalize("NFKC", s).replace("\u00a0", " ") return re.sub(r"\s+", " ", s).strip().casefold() def slide_lines(prs): """The visible text of every slide, as lines.""" return [[" ".join("".join(r.text for r in p.runs).split()) for p in _text_paragraphs(s) if "".join(r.text for r in p.runs).strip()] for s in prs.slides] def diff(src, dst, max_changed=4, max_added=2, keep_body=()): """(report, problems) comparing two decks slide by slide, by their words.""" a = slide_lines(Presentation(src)) b = slide_lines(Presentation(dst)) ka = [" ".join(_norm(x) for x in s) for s in a] kb = [" ".join(_norm(x) for x in s) for s in b] pairs = sorted(((SequenceMatcher(None, x, y, autojunk=False).ratio() if x != y else 1.0, i, j) for i, x in enumerate(ka) for j, y in enumerate(kb)), reverse=True) used_a, used_b, best = set(), set(), {} for sim, i, j in pairs: if sim < 0.35 or i in used_a or j in used_b: continue used_a.add(i) used_b.add(j) best[i] = (j, sim) kept = [i for i, (_, s) in best.items() if s >= 0.999] edited = sorted(i for i, (_, s) in best.items() if s < 0.999) dropped = [i for i in range(len(a)) if i not in best] added = [j for j in range(len(b)) if j not in used_b] out = ["%d/%d slides unchanged, %d edited, %d added, %d dropped" % (len(kept), len(a), len(edited), len(added), len(dropped))] problems = 0 for i in edited: j, sim = best[i] have = {_norm(x) for x in b[j]} stale = [l for l in a[i] if len(l.split()) >= 3 and _norm(l) in have] head_changed = [_norm(x) for x in a[i][:2]] != [_norm(x) for x in b[j][:2]] warn = "" if head_changed and len(stale) >= 3 and (i + 1) in keep_body: warn = " (new headline over the old body - allowed by --keep-body)" elif head_changed and len(stale) >= 3: problems += 1 warn = (" ! new headline over %d old lines, e.g. «%s» - rewrite the " "slide through (rewrite_slide)" % (len(stale), stale[0])) out.append(" slide %d -> %d: edited (%d%% similar)%s" % (i + 1, j + 1, round(sim * 100), warn)) for j in added: out.append(" slide %d: new" % (j + 1)) for i in dropped: out.append(" slide %d of the source: gone" % (i + 1)) if len(edited) + len(dropped) > max_changed: problems += 1 out.append("x %d of the person's slides changed (limit %d): an adaptation keeps " "the deck theirs. Revert the slides the request did not name, or ask " "the person first." % (len(edited) + len(dropped), max_changed)) if len(added) > max_added: problems += 1 out.append("x %d slides added (limit %d): add only what the request names, or " "ask the person first." % (len(added), max_added)) if problems: out.append("NOT DONE - fix the x/! above and run diff again.") return "\n".join(out), problems USAGE = """usage: plank_deck_edit.py inventory DECK.pptx plank_deck_edit.py diff ORIGINAL.pptx NEW.pptx [--max-changed N] [--max-added N] [--keep-body N --because ""] Editing itself is Python: `from plank_deck_edit import *` (see the module docstring).""" def main(argv): args = argv[1:] if not args or "--help" in args or "-h" in args: print(__doc__ if not args else USAGE) return 0 if args else 2 cmd, rest = args[0], args[1:] positional = [x for k, x in enumerate(rest) if not x.startswith("--") and (k == 0 or not rest[k - 1].startswith("--"))] try: if cmd == "inventory" and positional: print(inventory(open_deck(positional[0]))) return 0 if cmd == "diff" and len(positional) >= 2: def opt(name, default): return int(rest[rest.index(name) + 1]) if name in rest else default keep = tuple(int(rest[k + 1]) for k, x in enumerate(rest) if x == "--keep-body") because = rest[rest.index("--because") + 1].strip() if "--because" in rest else "" if keep and not because: raise EditError("--keep-body needs --because \"\". If nobody asked, the " "slide is half-edited: rewrite it through.") for path in positional[:2]: open_deck(path) text, problems = diff(positional[0], positional[1], max_changed=opt("--max-changed", 4), max_added=opt("--max-added", 2), keep_body=keep) print(text) if keep: print("Kept the old body under a new headline on slide(s) %s because the " "person asked: «%s». Say this in your reply." % (", ".join(map(str, keep)), because)) return 1 if problems else 0 except EditError as e: print("EditError: %s" % e, file=sys.stderr) return 1 print(USAGE, file=sys.stderr) return 2 __all__ = [ "EditError", "open_deck", "save", "inventory", "drop_slides", "keep_slides", "duplicate_slide", "move_slide", "replace_text", "set_text", "clear_links", "swap_picture", "rewrite_slide", "slide_lines", "diff", ] if __name__ == "__main__": raise SystemExit(main(sys.argv))