"""TemplateAssembler — Flow A 增强:单页模板拼装 设计师做好每种布局的单页 .pptx 模板,组合引擎: 1. 打开各单页模板 2. 拷贝幻灯片到目标 PPT 3. 对每页做文本/图表替换 用法: assembler = TemplateAssembler() assembler.add_slide_from_template("layouts/title.pptx", text_data={"标题": "报告标题"}) assembler.add_slide_from_template("layouts/chart.pptx", text_data={...}, chart_configs={...}) assembler.add_slide_from_template("layouts/table.pptx", text_data={...}) assembler.save("output.pptx") 也支持 layout_book 模式(一个 .pptx 包含多种布局,按页名选取): assembler = TemplateAssembler.from_layout_book("layout_book.pptx") assembler.add_slide_by_name("标题页", text_data={...}) assembler.add_slide_by_name("图表页", text_data={...}, chart_configs={...}) assembler.save("output.pptx") """ import sys import copy from pathlib import Path from typing import Dict, List, Optional, Any from pptx import Presentation from pptx.util import Inches from lxml import etree class TemplateAssembler: """单页模板拼装器""" def __init__(self): self.prs = None self._page_count = 0 self._layout_book = None # layout book 模式下的源 prs @classmethod def from_layout_book(cls, layout_book_path): """从 layout book(多页布局模板库)创建 layout book 是一个 .pptx 文件,每页代表一种布局, 页面中用占位符标记可替换内容。 """ assembler = cls() assembler._layout_book = Presentation(str(layout_book_path)) # 创建空的目标 prs,继承 layout book 的尺寸 assembler.prs = Presentation() assembler.prs.slide_width = assembler._layout_book.slide_width assembler.prs.slide_height = assembler._layout_book.slide_height return assembler def add_slide_from_template(self, template_path, *, text_data: Dict[str, str] = None, chart_configs: Dict = None): """从单页 .pptx 模板添加一页 Args: template_path: 单页 .pptx 模板路径 text_data: 文本占位符替换 {"{变量名}": "值"} chart_configs: 图表替换配置(与 TemplateReplacer 格式兼容) """ src_prs = Presentation(str(template_path)) if self.prs is None: self.prs = Presentation() self.prs.slide_width = src_prs.slide_width self.prs.slide_height = src_prs.slide_height # 拷贝第一张幻灯片 src_slide = src_prs.slides[0] new_slide = self._copy_slide(src_prs, src_slide) # 替换内容 self._apply_replacements(new_slide, text_data, chart_configs) self._page_count += 1 name = Path(template_path).stem print(f" ✅ 第 {self._page_count} 页: {name}", file=sys.stderr) return self def add_slide_by_name(self, slide_name: str, *, text_data: Dict[str, str] = None, chart_configs: Dict = None): """从 layout book 中按幻灯片名称/索引添加一页 slide_name 匹配规则: 1. 精确匹配幻灯片中的标题文本 2. 尝试作为索引("0", "1", ...) """ if self._layout_book is None: raise ValueError("需要先用 from_layout_book() 初始化") src_slide = self._find_slide(slide_name) if src_slide is None: available = self._list_slide_names() raise ValueError(f"找不到幻灯片 '{slide_name}'。可用: {available}") new_slide = self._copy_slide(self._layout_book, src_slide) self._apply_replacements(new_slide, text_data, chart_configs) self._page_count += 1 print(f" ✅ 第 {self._page_count} 页: {slide_name}", file=sys.stderr) return self def add_blank_slide(self): """添加空白页""" if self.prs is None: self.prs = Presentation() blank_layout = self.prs.slide_layouts[-1] self.prs.slides.add_slide(blank_layout) self._page_count += 1 return self def save(self, path): """保存""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) self.prs.save(str(path)) print(f"\n✅ PPT 已保存: {path} ({self._page_count} 页)", file=sys.stderr) return str(path) # ======================================================================== # 内部方法 # ======================================================================== def _copy_slide(self, src_prs, src_slide): """将源幻灯片拷贝到目标 prs(通过 XML 深拷贝)""" # 获取空白布局 blank_layout = self.prs.slide_layouts[-1] new_slide = self.prs.slides.add_slide(blank_layout) # 清除新幻灯片默认内容 sp_tree = new_slide._element.find( '{http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing}spTree', ) # 替换整个 cSld 内容 src_cSld = src_slide._element.find( '{http://schemas.openxmlformats.org/presentationml/2006/main}cSld' ) dst_cSld = new_slide._element.find( '{http://schemas.openxmlformats.org/presentationml/2006/main}cSld' ) if src_cSld is not None and dst_cSld is not None: # 深拷贝 spTree(形状树) src_spTree = src_cSld.find( '{http://schemas.openxmlformats.org/presentationml/2006/main}spTree' ) dst_spTree = dst_cSld.find( '{http://schemas.openxmlformats.org/presentationml/2006/main}spTree' ) # 如果找不到带 pml 命名空间的 spTree,尝试通用方式 if src_spTree is None: for child in src_cSld: if 'spTree' in child.tag: src_spTree = child break if dst_spTree is None: for child in dst_cSld: if 'spTree' in child.tag: dst_spTree = child break if src_spTree is not None and dst_spTree is not None: # 移除目标的所有 sp 子元素,保留 nvGrpSpPr 和 grpSpPr to_remove = [] for child in dst_spTree: tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag if tag not in ('nvGrpSpPr', 'grpSpPr'): to_remove.append(child) for child in to_remove: dst_spTree.remove(child) # 拷贝源的形状 for child in src_spTree: tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag if tag not in ('nvGrpSpPr', 'grpSpPr'): dst_spTree.append(copy.deepcopy(child)) # 拷贝背景 src_bg = src_cSld.find('{http://schemas.openxmlformats.org/presentationml/2006/main}bg') if src_bg is None: for child in src_cSld: if 'bg' in child.tag and 'spTree' not in child.tag: src_bg = child break if src_bg is not None: dst_bg = dst_cSld.find('{http://schemas.openxmlformats.org/presentationml/2006/main}bg') if dst_bg is None: for child in dst_cSld: if 'bg' in child.tag and 'spTree' not in child.tag: dst_bg = child break if dst_bg is not None: dst_cSld.remove(dst_bg) # 插入到 spTree 之前 dst_cSld.insert(0, copy.deepcopy(src_bg)) # 拷贝关联的图片等媒体资源 self._copy_slide_rels(src_prs, src_slide, new_slide) return new_slide def _copy_slide_rels(self, src_prs, src_slide, dst_slide): """拷贝幻灯片的关系文件(图片、嵌入对象等)""" from pptx.opc.constants import RELATIONSHIP_TYPE as RT for rel in src_slide.part.rels.values(): # 跳过布局关系 if rel.reltype == RT.SLIDE_LAYOUT: continue try: if rel.is_external: dst_slide.part.rels.get_or_add_ext_rel(rel.reltype, rel.target_ref) else: # 拷贝嵌入的部件(如图片) target_part = rel.target_part dst_slide.part.rels.get_or_add(rel.reltype, target_part) except Exception: pass # 跳过无法拷贝的关系 def _apply_replacements(self, slide, text_data, chart_configs): """对幻灯片应用文本和图表替换""" if not text_data and not chart_configs: return # 文本替换 if text_data: for shape in slide.shapes: if shape.has_text_frame: self._replace_text_in_shape(shape, text_data) if shape.has_table: for row in shape.table.rows: for cell in row.cells: for paragraph in cell.text_frame.paragraphs: self._replace_text_in_paragraph(paragraph, text_data) # 图表替换 if chart_configs: self._replace_charts(slide, chart_configs) def _replace_text_in_shape(self, shape, data): """替换形状中的文本占位符""" for paragraph in shape.text_frame.paragraphs: self._replace_text_in_paragraph(paragraph, data) def _replace_text_in_paragraph(self, paragraph, data): """替换段落中的 {占位符}""" full_text = paragraph.text if '{' not in full_text: return for key, value in data.items(): placeholder = f"{{{key}}}" if not key.startswith('{') else key if placeholder in full_text: full_text = full_text.replace(placeholder, str(value)) # 重写 run(保留第一个 run 的格式) if paragraph.runs: first_run = paragraph.runs[0] # 清除后续 run for run in paragraph.runs[1:]: run.text = "" first_run.text = full_text def _replace_charts(self, slide, chart_configs): """替换图表占位符(复用 TemplateReplacer 逻辑)""" from ppt_station.chart_builder.api import create_combo_chart shapes_to_process = list(slide.shapes) for shape in shapes_to_process: if not shape.has_text_frame: continue text = shape.text_frame.text.strip() # 匹配 {@ 图:xxx} 或 {@图|xxx} for chart_name, config in chart_configs.items(): markers = [f"{{@图:{chart_name}}}", f"{{@图|{chart_name}}}"] if text in markers: # 用图表替换此形状 left, top = shape.left, shape.top width, height = shape.width, shape.height # 删除占位符形状 sp = shape._element sp.getparent().remove(sp) # 创建图表 create_combo_chart( slide=slide, df=config["df"], categories_col=config["categories_col"], series_config=config["series_config"], position=(left, top), size=(width, height), style_config=config.get("style_config"), layout_config=config.get("layout_config"), ) break def _find_slide(self, name: str): """在 layout book 中按名称或索引查找幻灯片""" # 尝试按索引 try: idx = int(name) if 0 <= idx < len(self._layout_book.slides): return self._layout_book.slides[idx] except ValueError: pass # 按标题文本匹配 for slide in self._layout_book.slides: for shape in slide.shapes: if shape.has_text_frame: if name in shape.text_frame.text: return slide return None def _list_slide_names(self): """列出 layout book 中所有幻灯片的标题""" names = [] for i, slide in enumerate(self._layout_book.slides): title = f"[{i}]" for shape in slide.shapes: if shape.has_text_frame and shape.text_frame.text.strip(): title = f"[{i}] {shape.text_frame.text.strip()[:30]}" break names.append(title) return names