#!/usr/bin/env python3 from __future__ import annotations import argparse import re import tempfile import zipfile from pathlib import Path from typing import Any, Iterable, Optional from _pptx_common import ( OOXML_PRESENTATION_SUFFIXES, SkillArgumentParser, input_file, load_json_argument, output_file, parse_xml_bytes, publish_file, run_cli, ) def build_parser() -> argparse.ArgumentParser: parser = SkillArgumentParser(description="按受控操作编辑 PowerPoint 演示文稿。") parser.add_argument("--input", required=True) parser.add_argument("--output", required=True) parser.add_argument("--spec") parser.add_argument("--spec-file") parser.add_argument("--allow-external-links", action="store_true") parser.add_argument("--overwrite", action="store_true") return parser def _external_relationship_count(path: Path) -> int: count = 0 with zipfile.ZipFile(path) as archive: for name in archive.namelist(): if not name.endswith(".rels"): continue root = parse_xml_bytes(archive.read(name), label=name) count += sum( 1 for node in root if node.attrib.get("TargetMode") == "External" ) return count def _package_risks(path: Path) -> list[str]: risks: list[str] = [] with zipfile.ZipFile(path) as archive: names = archive.namelist() if any(name.startswith("ppt/comments/") for name in names): risks.append("comments") if any( name.endswith((".bin", ".vbaProject")) for name in names ): risks.append("binary_embedded_objects_or_macros") if any(name.startswith("ppt/activeX/") for name in names): risks.append("activex") return risks def _iter_shapes(shapes: Any) -> Iterable[Any]: from pptx.enum.shapes import MSO_SHAPE_TYPE for shape in shapes: yield shape if shape.shape_type == MSO_SHAPE_TYPE.GROUP: yield from _iter_shapes(shape.shapes) def _iter_text_frames(slide: Any, *, include_notes: bool) -> Iterable[Any]: for shape in _iter_shapes(slide.shapes): if getattr(shape, "has_text_frame", False): yield shape.text_frame if getattr(shape, "has_table", False): for row in shape.table.rows: for cell in row.cells: yield cell.text_frame if include_notes: try: text_frame = slide.notes_slide.notes_text_frame if text_frame is not None: yield text_frame except (AttributeError, KeyError, ValueError): pass def _find_spans( text: str, needle: str, *, match_case: bool, whole_word: bool, limit: Optional[int], ) -> list[tuple[int, int]]: flags = 0 if match_case else re.IGNORECASE escaped = re.escape(needle) if whole_word: escaped = rf"(? tuple[int, int]: cursor = 0 for index, run in enumerate(runs): next_cursor = cursor + len(run.text) if offset < next_cursor or (end and offset == next_cursor): return index, offset - cursor cursor = next_cursor if not runs: raise ValueError("段落没有可编辑的文本 Run") return len(runs) - 1, len(runs[-1].text) def _replace_in_paragraph( paragraph: Any, needle: str, replacement: str, *, match_case: bool, whole_word: bool, limit: Optional[int], ) -> int: runs = list(paragraph.runs) if not runs: return 0 text = "".join(run.text for run in runs) spans = _find_spans( text, needle, match_case=match_case, whole_word=whole_word, limit=limit, ) for start, end in reversed(spans): start_index, start_offset = _run_at_offset(runs, start) end_index, end_offset = _run_at_offset(runs, end, end=True) if start_index == end_index: original = runs[start_index].text runs[start_index].text = ( original[:start_offset] + replacement + original[end_offset:] ) continue prefix = runs[start_index].text[:start_offset] suffix = runs[end_index].text[end_offset:] runs[start_index].text = prefix + replacement for index in range(start_index + 1, end_index): runs[index].text = "" runs[end_index].text = suffix return len(spans) def _selected_slides(presentation: Any, indexes: Optional[list[Any]]) -> list[Any]: if indexes is None: return list(presentation.slides) if not isinstance(indexes, list) or not indexes: raise ValueError("slides 必须是非空页码数组") selected: list[Any] = [] for value in indexes: if not isinstance(value, int) or isinstance(value, bool): raise ValueError("slides 中的页码必须是整数") if value < 1 or value > len(presentation.slides): raise ValueError(f"页码超出范围:{value}") selected.append(presentation.slides[value - 1]) return selected def _replace_text(presentation: Any, operation: dict[str, Any]) -> dict[str, Any]: needle = operation.get("find") replacement = operation.get("replace") if not isinstance(needle, str) or not needle: raise ValueError("replace_text.find 必须是非空字符串") if not isinstance(replacement, str): raise ValueError("replace_text.replace 必须是字符串") match_case = bool(operation.get("match_case", True)) whole_word = bool(operation.get("whole_word", False)) required = bool(operation.get("required", True)) include_notes = bool(operation.get("include_notes", False)) limit_value = operation.get("count") if limit_value is not None: if ( not isinstance(limit_value, int) or isinstance(limit_value, bool) or limit_value < 1 or limit_value > 10000 ): raise ValueError("replace_text.count 必须是 1 到 10000 的整数") remaining = limit_value changed = 0 for slide in _selected_slides(presentation, operation.get("slides")): for text_frame in _iter_text_frames(slide, include_notes=include_notes): for paragraph in text_frame.paragraphs: per_paragraph_limit = remaining replacements = _replace_in_paragraph( paragraph, needle, replacement, match_case=match_case, whole_word=whole_word, limit=per_paragraph_limit, ) changed += replacements if remaining is not None: remaining -= replacements if remaining <= 0: break if remaining is not None and remaining <= 0: break if remaining is not None and remaining <= 0: break if required and changed == 0: raise ValueError(f"未找到必须替换的文本:{needle}") return {"type": "replace_text", "replacement_count": changed} def _set_properties(presentation: Any, operation: dict[str, Any]) -> dict[str, Any]: properties = operation.get("properties") if not isinstance(properties, dict): raise ValueError("set_properties.properties 必须是对象") allowed = { "title", "subject", "author", "keywords", "comments", "category", "content_status", "identifier", "language", "last_modified_by", "revision", "version", } changed: list[str] = [] for key, value in properties.items(): if key not in allowed: raise ValueError(f"set_properties 不支持字段:{key}") if key == "revision": if ( not isinstance(value, int) or isinstance(value, bool) or value < 1 ): raise ValueError("set_properties.revision 必须是正整数") elif value is not None and not isinstance(value, str): raise ValueError(f"set_properties.{key} 必须是字符串或 null") setattr(presentation.core_properties, key, value) changed.append(key) return {"type": "set_properties", "fields": changed} def _delete_slides(presentation: Any, operation: dict[str, Any]) -> dict[str, Any]: indexes = operation.get("slides") if not isinstance(indexes, list) or not indexes: raise ValueError("delete_slides.slides 必须是非空页码数组") normalized: set[int] = set() for value in indexes: if not isinstance(value, int) or isinstance(value, bool): raise ValueError("delete_slides.slides 中的页码必须是整数") if value < 1 or value > len(presentation.slides): raise ValueError(f"要删除的页码超出范围:{value}") normalized.add(value) if len(normalized) >= len(presentation.slides): raise ValueError("不能删除演示文稿中的全部页面") slide_ids = presentation.slides._sldIdLst for index in sorted(normalized, reverse=True): slide_id = slide_ids[index - 1] relationship_id = slide_id.rId slide_ids.remove(slide_id) presentation.part.drop_rel(relationship_id) return {"type": "delete_slides", "deleted": sorted(normalized)} def _reorder_slides(presentation: Any, operation: dict[str, Any]) -> dict[str, Any]: order = operation.get("order") expected = list(range(1, len(presentation.slides) + 1)) if ( not isinstance(order, list) or any( not isinstance(value, int) or isinstance(value, bool) for value in order ) or sorted(order) != expected ): raise ValueError( "reorder_slides.order 必须完整且不重复地列出当前全部页码" ) slide_ids = presentation.slides._sldIdLst original = list(slide_ids) for slide_id in original: slide_ids.remove(slide_id) for number in order: slide_ids.append(original[number - 1]) return {"type": "reorder_slides", "order": order} def _apply_operation(presentation: Any, operation: dict[str, Any]) -> dict[str, Any]: operation_type = operation.get("type") if operation_type == "replace_text": return _replace_text(presentation, operation) if operation_type == "set_properties": return _set_properties(presentation, operation) if operation_type == "delete_slides": return _delete_slides(presentation, operation) if operation_type == "reorder_slides": return _reorder_slides(presentation, operation) raise ValueError(f"不支持的编辑操作:{operation_type}") def main() -> dict[str, Any]: from pptx import Presentation args = build_parser().parse_args() source = input_file(args.input, OOXML_PRESENTATION_SUFFIXES) destination = output_file(args.output, {".pptx"}, overwrite=args.overwrite) spec = load_json_argument(args.spec, args.spec_file, label="编辑说明") operations = spec.get("operations") if not isinstance(operations, list) or not operations: raise ValueError("编辑说明的 operations 必须是非空数组") if len(operations) > 1000: raise ValueError("编辑操作不能超过 1000 项") external_relationships = _external_relationship_count(source) if external_relationships and not args.allow_external_links: raise ValueError( "输入演示文稿包含外部链接;如用户接受外部链接可能变化的风险," "请显式传 --allow-external-links" ) risks = _package_risks(source) if risks: raise ValueError( "输入演示文稿包含 python-pptx 不能可靠保留的内容:" + "、".join(risks) + ";请改用 unpack_presentation.py 做最小化 OOXML 编辑" ) presentation = Presentation(str(source)) results: list[dict[str, Any]] = [] for index, operation in enumerate(operations): if not isinstance(operation, dict): raise ValueError(f"operations[{index}] 必须是对象") results.append(_apply_operation(presentation, operation)) with tempfile.TemporaryDirectory(prefix="pptx-edit-") as temp_name: staged = Path(temp_name) / "edited.pptx" presentation.save(str(staged)) reopened = Presentation(str(staged)) slide_count = len(reopened.slides) publish_file(staged, destination, overwrite=args.overwrite) return { "source": str(source), "path": str(destination), "slide_count": slide_count, "external_relationship_count": external_relationships, "operations": results, } if __name__ == "__main__": raise SystemExit(run_cli(main))