mirror of
https://github.com/anthropics/skills.git
synced 2026-08-02 21:15:27 +08:00
fa0fa64bdc
Add support for template formats (.dotx, .potx, .xltx) across validation and the helper scripts. Consolidate the shared office helpers into a single module, replacing the pack/unpack pipeline with explicit zip/unzip steps. Extraction now rejects symlink and path-traversal archive entries. Move run merging to a standalone docx script, since it only ever applied to Word documents. Fix the redlining validator so it compares against the original even when a document has no tracked changes, which is when an untracked edit would otherwise go unreported. Provision a LibreOffice user profile per invocation so conversions work in sandboxed environments. Trim the skill docs to the guidance that earns its place.
368 lines
14 KiB
Python
Executable File
368 lines
14 KiB
Python
Executable 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()
|