| Server IP : 91.134.83.25 / Your IP : 216.73.216.147 Web Server : Apache System : Linux plesk.serveurapc.fr 6.1.0-51-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.177-1 (2026-07-16) x86_64 User : marrasse ( 10057) PHP Version : 8.2.32 Disable Function : opcache_get_status MySQL : OFF | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /var/www/vhosts/as-cp.fr/.claude/remote/plugins/f5be4c412689f929/skills/pptx/scripts/ |
Upload File : |
"""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 <p:sldId id="..." r:id="..."/> with a fresh id into
<p:sldIdLst> — 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 <dir>/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 = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
</p:spTree>
</p:cSld>
<p:clrMapOvr>
<a:masterClrMapping/>
</p:clrMapOvr>
</p:sld>'''
SHARED_PART_TYPES = ("chart", "diagramData", "oleObject", "package")
NOTES_SLIDE_TYPE_RE = re.compile(r"""Type=["'][^"']*/relationships/notesSlide["']""")
RELATIONSHIP_RE = re.compile(r"<Relationship\b[^>]*?(?:/>|>.*?</Relationship\s*>)", 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'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/{layout_file}"/>
</Relationships>'''
(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 = (
"</p:sldIdLst>" in xml
or re.search(r"<p:sldIdLst\s*/>", xml)
or "</p:sldMasterIdLst>" in xml
)
if not has_slot:
_die("presentation.xml has no <p:sldIdLst> (or <p:sldMasterIdLst> 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'<p:sldId\b[^>]*r:id="{re.escape(after_rid)}"[^>]*>', xml):
_die(f"{after} ({after_rid}) is not listed in <p:sldIdLst>")
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 <p:sldId id="{slide_id}" r:id="{rid}"/> into <p:sldIdLst> '
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'<Override PartName="/ppt/slides/{dest}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'
if f'PartName="/ppt/slides/{dest}"' not in content_types:
content_types = content_types.replace("</Types>", f" {new_override}\n</Types>")
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'<Relationship Id="{rid}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/{dest}"/>'
pres_rels = pres_rels.replace("</Relationships>", f" {new_rel}\n</Relationships>")
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"<Relationship\b[^>]*>", 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'<p:sldId[^>]*\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'<p:sldId id="{slide_id}" r:id="{rid}"/>'
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'<p:sldId\b[^>]*r:id="{re.escape(after_rid)}"[^>]*>', xml)
if not open_tag:
_die(f"{after_rid} is not listed in <p:sldIdLst>")
end = open_tag.end()
if not open_tag.group(0).endswith("/>"):
close = xml.find("</p:sldId>", end)
if close == -1:
_die(f"unclosed <p:sldId> for {after_rid} in presentation.xml")
end = close + len("</p:sldId>")
xml = xml[:end] + entry + xml[end:]
elif "</p:sldIdLst>" in xml:
xml = xml.replace("</p:sldIdLst>", f"{entry}</p:sldIdLst>", 1)
elif re.search(r"<p:sldIdLst\s*/>", xml):
xml = re.sub(r"<p:sldIdLst\s*/>", f"<p:sldIdLst>{entry}</p:sldIdLst>", xml, count=1)
elif "</p:sldMasterIdLst>" in xml:
xml = xml.replace(
"</p:sldMasterIdLst>", f"</p:sldMasterIdLst><p:sldIdLst>{entry}</p:sldIdLst>", 1
)
else:
_die("presentation.xml has no <p:sldIdLst> (or <p:sldMasterIdLst> to anchor a new one)")
pres_path.write_text(xml, encoding="utf-8")
lst = re.search(r"<p:sldIdLst>(.*)</p:sldIdLst>", xml, re.DOTALL)
entries = re.findall(r"<p:sldId\b[^>]*>", 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 <p:sldIdLst>."
)
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 <dir>/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()