wechat-robot-skills/skills/pptx/scripts/duplicate_slide.py

293 lines
9.9 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import posixpath
import re
import tempfile
import zipfile
from pathlib import Path, PurePosixPath
from typing import Any
from _pptx_common import (
P_NS,
R_NS,
REL_NS,
SkillArgumentParser,
input_file,
inspect_archive,
output_file,
publish_file,
run_cli,
)
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
SLIDE_REL_TYPE_SUFFIX = "/slide"
SLIDE_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument."
"presentationml.slide+xml"
)
DROP_RELATIONSHIP_SUFFIXES = ("/notesSlide", "/comments", "/comment")
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="安全复制现有 PPTX 页面并更新 OOXML 包关系。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--slide", type=int, required=True)
parser.add_argument("--after", type=int)
parser.add_argument("--overwrite", action="store_true")
return parser
def _parse_xml(payload: bytes, label: str) -> Any:
from lxml import etree
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
recover=False,
huge_tree=False,
remove_blank_text=False,
)
try:
return etree.fromstring(payload, parser=parser)
except etree.XMLSyntaxError as exc:
raise ValueError(f"{label} 解析失败:{exc}") from exc
def _serialize_xml(root: Any) -> bytes:
from lxml import etree
return etree.tostring(
root,
encoding="UTF-8",
xml_declaration=True,
standalone=True,
)
def _next_relationship_id(existing: set[str]) -> str:
number = 1
while f"rId{number}" in existing:
number += 1
return f"rId{number}"
def _slide_rels_name(slide_part: str) -> str:
path = PurePosixPath(slide_part)
return (path.parent / "_rels" / f"{path.name}.rels").as_posix()
def _resolve_part_target(owner_part: str, target: str) -> str:
if not target or target.startswith("#"):
raise ValueError(f"关系目标无效:{target!r}")
if target.startswith("/"):
normalized = posixpath.normpath(target).lstrip("/")
else:
normalized = posixpath.normpath(
posixpath.join(posixpath.dirname(owner_part), target)
)
if normalized == ".." or normalized.startswith("../"):
raise ValueError(f"关系目标逃逸 OOXML 包根目录:{target}")
return normalized.lstrip("/")
def _duplicate(
source: Path,
staged: Path,
*,
source_slide: int,
insert_after: int,
) -> dict[str, Any]:
with zipfile.ZipFile(source, "r") as incoming:
infos = incoming.infolist()
names = {info.filename for info in infos}
presentation_root = _parse_xml(
incoming.read("ppt/presentation.xml"),
"ppt/presentation.xml",
)
presentation_rels_root = _parse_xml(
incoming.read("ppt/_rels/presentation.xml.rels"),
"ppt/_rels/presentation.xml.rels",
)
content_types_root = _parse_xml(
incoming.read("[Content_Types].xml"),
"[Content_Types].xml",
)
slide_id_list = presentation_root.find(f"{{{P_NS}}}sldIdLst")
if slide_id_list is None:
raise ValueError("presentation.xml 缺少 sldIdLst")
slide_ids = list(slide_id_list)
if source_slide < 1 or source_slide > len(slide_ids):
raise ValueError(f"slide 超出页面总数 {len(slide_ids)}")
if insert_after < 1 or insert_after > len(slide_ids):
raise ValueError(f"after 超出页面总数 {len(slide_ids)}")
rel_targets: dict[str, str] = {}
existing_rel_ids: set[str] = set()
slide_relationship_type = None
for relationship in presentation_rels_root:
relationship_id = relationship.attrib.get("Id", "")
existing_rel_ids.add(relationship_id)
target = relationship.attrib.get("Target", "")
if relationship.attrib.get("TargetMode") == "External":
continue
posix_target = _resolve_part_target(
"ppt/presentation.xml",
target,
)
rel_targets[relationship_id] = posix_target
if relationship.attrib.get("Type", "").endswith(SLIDE_REL_TYPE_SUFFIX):
slide_relationship_type = relationship.attrib.get("Type")
if not slide_relationship_type:
raise ValueError("presentation.xml.rels 不包含 slide 关系类型")
source_rel_id = slide_ids[source_slide - 1].attrib.get(f"{{{R_NS}}}id")
source_part = rel_targets.get(source_rel_id or "")
if not source_part or source_part not in names:
raise ValueError("无法解析待复制页面的 slide 部件")
existing_numbers = [
int(match.group(1))
for name in names
if (
match := re.fullmatch(r"ppt/slides/slide(\d+)\.xml", name)
)
]
new_part_number = max(existing_numbers, default=0) + 1
new_part = f"ppt/slides/slide{new_part_number}.xml"
new_rels_part = _slide_rels_name(new_part)
new_rel_id = _next_relationship_id(existing_rel_ids)
numeric_ids = [
int(item.attrib["id"])
for item in slide_ids
if item.attrib.get("id", "").isdigit()
]
new_slide_id = str(max(numeric_ids, default=255) + 1)
from lxml import etree
new_relationship = etree.Element(
f"{{{REL_NS}}}Relationship",
Id=new_rel_id,
Type=slide_relationship_type,
Target=f"slides/slide{new_part_number}.xml",
)
presentation_rels_root.append(new_relationship)
new_slide_id_element = etree.Element(
f"{{{P_NS}}}sldId",
id=new_slide_id,
)
new_slide_id_element.set(f"{{{R_NS}}}id", new_rel_id)
slide_id_list.insert(insert_after, new_slide_id_element)
override_exists = any(
node.attrib.get("PartName") == f"/{new_part}"
for node in content_types_root.findall(
f"{{{CONTENT_TYPES_NS}}}Override"
)
)
if not override_exists:
content_types_root.append(
etree.Element(
f"{{{CONTENT_TYPES_NS}}}Override",
PartName=f"/{new_part}",
ContentType=SLIDE_CONTENT_TYPE,
)
)
replacements = {
"ppt/presentation.xml": _serialize_xml(presentation_root),
"ppt/_rels/presentation.xml.rels": _serialize_xml(
presentation_rels_root
),
"[Content_Types].xml": _serialize_xml(content_types_root),
}
additions = {new_part: incoming.read(source_part)}
source_rels_part = _slide_rels_name(source_part)
dropped_relationships: list[str] = []
shared_relationships: list[dict[str, str]] = []
if source_rels_part in names:
slide_rels_root = _parse_xml(
incoming.read(source_rels_part),
source_rels_part,
)
for relationship in list(slide_rels_root):
relationship_type = relationship.attrib.get("Type", "")
if relationship_type.endswith(DROP_RELATIONSHIP_SUFFIXES):
dropped_relationships.append(relationship_type)
slide_rels_root.remove(relationship)
continue
if relationship_type.endswith(
("/chart", "/diagramData", "/diagramDrawing", "/oleObject")
):
shared_relationships.append(
{
"type": relationship_type,
"target": relationship.attrib.get("Target", ""),
}
)
additions[new_rels_part] = _serialize_xml(slide_rels_root)
with zipfile.ZipFile(
staged,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=6,
) as outgoing:
for info in infos:
payload = replacements.get(info.filename)
if payload is None:
payload = incoming.read(info.filename)
outgoing.writestr(info, payload)
for name, payload in additions.items():
outgoing.writestr(name, payload)
archive = inspect_archive(staged)
return {
"source_slide": source_slide,
"insert_after": insert_after,
"new_slide": insert_after + 1,
"new_slide_part": new_part,
"dropped_relationship_types": dropped_relationships,
"shared_relationships": shared_relationships,
"archive": archive,
}
def main() -> dict[str, Any]:
from pptx import Presentation
args = build_parser().parse_args()
source = input_file(args.input, {".pptx"})
destination = output_file(args.output, {".pptx"}, overwrite=args.overwrite)
if source == destination:
raise ValueError("不能覆盖输入演示文稿;请使用新的 output 路径")
insert_after = args.slide if args.after is None else args.after
with tempfile.TemporaryDirectory(prefix="pptx-duplicate-") as temp_name:
staged = Path(temp_name) / "duplicated.pptx"
result = _duplicate(
source,
staged,
source_slide=args.slide,
insert_after=insert_after,
)
presentation = Presentation(str(staged))
result["slide_count"] = len(presentation.slides)
publish_file(staged, destination, overwrite=args.overwrite)
return {
"source": str(source),
"path": str(destination),
**result,
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))