skills/ppt-station-skill/ppt_station/composer/template_assembler.py
wangyitong a65adcc2e5 Initial commit: merged, deduplicated, and vetted skill collection
Sources: extracted from two upstream archives (skill-repo, skills-main),
merged with the following policy:

- 15 broken symlinks (pointing to /Users/jameslee/.cc-switch/skills or
  ../../.agents/skills on a foreign machine) discarded
- 3 real name collisions with identical content (ai-pair, ifind-http-api,
  zhipu-websearch) kept as one copy
- Functional overlaps deduped keeping the strongest variant:
  - docx family: kept docx (official, full toolchain) + docx-cn
    (GB/T 9704 Chinese official-document constants),
    dropped docx_writer (no scripts, name collided with docx)
  - humanizer family: kept humanizer-zh (6 zh reference docs),
    dropped humanizer (en, redundant for CN workflow)
- Skills that only ran in a foreign environment removed:
  ablemind-ops, app-publish, hlb-design-system, openclaw-adj-skill,
  claude-driver
- alphapai excluded from this public repo because its SKILL.md hard-coded
  live credentials

Result: 25 skills, 572 files, ~7.5 MB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:47:12 +08:00

338 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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