293 lines
9.9 KiB
Python
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))
|