skills/ppt-station-skill/ppt_station/composer/template_assembler.py

338 lines
13 KiB
Python
Raw Permalink Normal View History

"""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