"""Add a slide to a PPTX: duplicate an existing slide or instantiate a layout. Does all of the package bookkeeping, so the deck stays valid: - writes the new ppt/slides/slideN.xml (and its .rels, minus any notesSlide reference, so the source's speaker notes aren't shared) - registers it in [Content_Types].xml - adds a slide relationship with a fresh rId to presentation.xml.rels - inserts with a fresh id into — at the end, or after --after SLIDE Works on an unpacked directory (during an editing session) or directly on a .pptx/.potx file (extracted to a temp dir, then rezipped atomically; the temp dir is discarded, so unpack the output if you still need to edit the new slide's content). Usage: python add_slide.py unpacked/ slide2.xml # duplicate slide2 python add_slide.py unpacked/ slideLayout3.xml # new slide from a layout python add_slide.py unpacked/ slide2.xml --after slide2.xml python add_slide.py deck.pptx slide2.xml # rewrite deck.pptx in place python add_slide.py deck.pptx slide2.xml -o out.pptx A duplicated slide still holds the source's content: edit ppt/slides/slideN.xml (printed on success) to change it. To list layouts: ls /ppt/slideLayouts/ """ import argparse import re import shutil import sys from typing import NoReturn import tempfile import zipfile from pathlib import Path from office.helpers import rezip, safe_extract MINIMAL_SLIDE_XML = ''' ''' SHARED_PART_TYPES = ("chart", "diagramData", "oleObject", "package") NOTES_SLIDE_TYPE_RE = re.compile(r"""Type=["'][^"']*/relationships/notesSlide["']""") RELATIONSHIP_RE = re.compile(r"]*?(?:/>|>.*?)", re.DOTALL) SLIDE_ID_MIN = 256 SLIDE_ID_MAX = 2147483647 def _die(msg: str) -> NoReturn: print(f"Error: {msg}", file=sys.stderr) sys.exit(1) def get_next_slide_number(slides_dir: Path) -> int: existing = [int(m.group(1)) for f in slides_dir.glob("slide*.xml") if (m := re.match(r"slide(\d+)\.xml", f.name))] return max(existing) + 1 if existing else 1 def parse_source(source: str) -> tuple[str, str | None]: if source.startswith("slideLayout") and source.endswith(".xml"): return ("layout", source) return ("slide", None) def create_slide_from_layout(unpacked_dir: Path, layout_file: str, after: str | None = None) -> str: slides_dir = unpacked_dir / "ppt" / "slides" rels_dir = slides_dir / "_rels" layout_path = unpacked_dir / "ppt" / "slideLayouts" / layout_file if not layout_path.exists(): _die(f"{layout_path} not found") next_num = get_next_slide_number(slides_dir) dest = f"slide{next_num}.xml" after_rid = _precheck_registration(unpacked_dir, after, dest) slides_dir.mkdir(parents=True, exist_ok=True) (slides_dir / dest).write_text(MINIMAL_SLIDE_XML, encoding="utf-8") rels_dir.mkdir(exist_ok=True) rels_xml = f''' ''' (rels_dir / f"{dest}.rels").write_text(rels_xml, encoding="utf-8") _register_slide(unpacked_dir, dest, layout_file, after_rid) return dest def duplicate_slide(unpacked_dir: Path, source: str, after: str | None = None) -> str: slides_dir = unpacked_dir / "ppt" / "slides" rels_dir = slides_dir / "_rels" source_slide = slides_dir / source if not source_slide.exists(): _die(f"{source_slide} not found") next_num = get_next_slide_number(slides_dir) dest = f"slide{next_num}.xml" after_rid = _precheck_registration(unpacked_dir, after, dest) shutil.copy2(source_slide, slides_dir / dest) source_rels = rels_dir / f"{source}.rels" shared_parts: list[str] = [] if source_rels.exists(): dest_rels = rels_dir / f"{dest}.rels" shutil.copy2(source_rels, dest_rels) rels_content = dest_rels.read_text(encoding="utf-8") rels_content = RELATIONSHIP_RE.sub( lambda m: "" if NOTES_SLIDE_TYPE_RE.search(m.group(0)) else m.group(0), rels_content, ) dest_rels.write_text(rels_content, encoding="utf-8") shared_parts = sorted({ t for t in re.findall(r'Type="[^"]*/relationships/(\w+)"', rels_content) if t in SHARED_PART_TYPES }) _register_slide(unpacked_dir, dest, source, after_rid) if shared_parts: print( f"Note: {dest} shares its {', '.join(shared_parts)} part(s) with {source} " f"(they are referenced, not copied) — editing those parts changes both slides" ) return dest def _precheck_registration(unpacked_dir: Path, after: str | None, dest: str) -> str | None: pres_path = unpacked_dir / "ppt" / "presentation.xml" if not pres_path.exists(): _die(f"{pres_path} not found — is this an unpacked PPTX?") xml = pres_path.read_text(encoding="utf-8") has_slot = ( "" in xml or re.search(r"", xml) or "" in xml ) if not has_slot: _die("presentation.xml has no (or to anchor a new one)") stale = [] content_types = unpacked_dir / "[Content_Types].xml" if content_types.exists() and f'PartName="/ppt/slides/{dest}"' in content_types.read_text(encoding="utf-8"): stale.append("[Content_Types].xml") pres_rels = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" if pres_rels.exists() and _find_slide_relationship( pres_rels.read_text(encoding="utf-8"), dest ): stale.append("presentation.xml.rels") if stale: _die( f"{dest} is still registered in {' and '.join(stale)} but absent from ppt/slides/ — " f"run clean.py first" ) if not after: return None after_rid = _rid_for_slide(unpacked_dir, after) if not re.search(rf']*r:id="{re.escape(after_rid)}"[^>]*>', xml): _die(f"{after} ({after_rid}) is not listed in ") return after_rid def _register_slide(unpacked_dir: Path, dest: str, source_desc: str, after_rid: str | None) -> None: _add_to_content_types(unpacked_dir, dest) rid = _add_to_presentation_rels(unpacked_dir, dest) slide_id = _get_next_slide_id(unpacked_dir) pos, total = _insert_into_sld_id_lst(unpacked_dir, slide_id, rid, after_rid) print(f"Created ppt/slides/{dest} from {source_desc}") print( f'Inserted into ' f"at position {pos} of {total}" ) def _add_to_content_types(unpacked_dir: Path, dest: str) -> None: content_types_path = unpacked_dir / "[Content_Types].xml" content_types = content_types_path.read_text(encoding="utf-8") new_override = f'' if f'PartName="/ppt/slides/{dest}"' not in content_types: content_types = content_types.replace("", f" {new_override}\n") content_types_path.write_text(content_types, encoding="utf-8") def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str: pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" pres_rels = pres_rels_path.read_text(encoding="utf-8") existing = _find_slide_relationship(pres_rels, dest) if existing: return existing pres_xml = (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8") used = {int(n) for n in re.findall(r'\bId="rId(\d+)"', pres_rels)} used |= {int(n) for n in re.findall(r'\br:id="rId(\d+)"', pres_xml)} rid = f"rId{max(used) + 1 if used else 1}" new_rel = f'' pres_rels = pres_rels.replace("", f" {new_rel}\n") pres_rels_path.write_text(pres_rels, encoding="utf-8") return rid def _find_slide_relationship(pres_rels: str, slide_name: str) -> str | None: for m in re.finditer(r"]*>", pres_rels): element = m.group(0) if re.search(rf'Target="(?:/ppt/)?slides/{re.escape(slide_name)}"', element): id_match = re.search(r'\bId="([^"]+)"', element) if id_match: return id_match.group(1) return None def _get_next_slide_id(unpacked_dir: Path) -> int: pres_content = (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8") used = {int(m) for m in re.findall(r']*\bid="(\d+)"', pres_content)} candidate = max((i for i in used if i >= SLIDE_ID_MIN), default=SLIDE_ID_MIN - 1) + 1 if candidate <= SLIDE_ID_MAX and candidate not in used: return candidate for i in range(SLIDE_ID_MIN, SLIDE_ID_MAX + 1): if i not in used: return i _die("no slide id available in [256, 2147483647] — the deck is full") def _insert_into_sld_id_lst( unpacked_dir: Path, slide_id: int, rid: str, after_rid: str | None = None ) -> tuple[int, int]: pres_path = unpacked_dir / "ppt" / "presentation.xml" xml = pres_path.read_text(encoding="utf-8") entry = f'' if f'r:id="{rid}"' in xml: _die(f"presentation.xml already references {rid}; refusing to add a duplicate") if after_rid: open_tag = re.search(rf']*r:id="{re.escape(after_rid)}"[^>]*>', xml) if not open_tag: _die(f"{after_rid} is not listed in ") end = open_tag.end() if not open_tag.group(0).endswith("/>"): close = xml.find("", end) if close == -1: _die(f"unclosed for {after_rid} in presentation.xml") end = close + len("") xml = xml[:end] + entry + xml[end:] elif "" in xml: xml = xml.replace("", f"{entry}", 1) elif re.search(r"", xml): xml = re.sub(r"", f"{entry}", xml, count=1) elif "" in xml: xml = xml.replace( "", f"{entry}", 1 ) else: _die("presentation.xml has no (or to anchor a new one)") pres_path.write_text(xml, encoding="utf-8") lst = re.search(r"(.*)", xml, re.DOTALL) entries = re.findall(r"]*>", lst.group(1)) if lst else [] position = next( (i for i, e in enumerate(entries, 1) if f'r:id="{rid}"' in e), len(entries) ) return position, len(entries) def _rid_for_slide(unpacked_dir: Path, slide_name: str) -> str: pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels" rid = _find_slide_relationship(pres_rels_path.read_text(encoding="utf-8"), slide_name) if not rid: _die(f"{slide_name} has no relationship in presentation.xml.rels") return rid def add_slide(unpacked_dir: Path, source: str, after: str | None = None) -> str: source_type, layout_file = parse_source(source) if source_type == "layout" and layout_file is not None: return create_slide_from_layout(unpacked_dir, layout_file, after) return duplicate_slide(unpacked_dir, source, after) def add_slide_to_package( package: Path, source: str, after: str | None = None, output: Path | None = None ) -> str: out = output or package with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) with zipfile.ZipFile(package) as zf: safe_extract(zf, tmp_path) dest = add_slide(tmp_path, source, after) rezip(tmp_path, out) print(f"Wrote {out} — the new slide is ppt/slides/{dest} inside it (unpack to edit its content)") return dest def main() -> None: parser = argparse.ArgumentParser( description="Add a slide to a PPTX: duplicate a slide or instantiate a layout. " "Registers content types, relationships, and ." ) parser.add_argument("target", help="Unpacked PPTX directory OR a .pptx/.potx file") parser.add_argument( "source", help="slideN.xml to duplicate, or slideLayoutN.xml to create from a layout " "(list layouts with: ls /ppt/slideLayouts/)", ) parser.add_argument( "--after", metavar="SLIDE", help="insert after this slide, e.g. slide2.xml (default: append at the end)", ) parser.add_argument( "-o", "--output", help="output file (only with a .pptx/.potx target; default: rewrite the input in place)", ) args = parser.parse_args() target = Path(args.target) if target.is_dir(): if args.output: parser.error("--output is only valid for .pptx/.potx input; a directory is modified in place") add_slide(target, args.source, args.after) elif target.is_file() and target.suffix.lower() in (".pptx", ".potx"): try: add_slide_to_package(target, args.source, args.after, Path(args.output) if args.output else None) except (OSError, ValueError, zipfile.BadZipFile) as e: _die(str(e)) else: _die(f"{target} is neither a directory nor a .pptx/.potx file") if __name__ == "__main__": main()