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

161 lines
5.3 KiB
Python
Raw Normal View History

"""PageComposer — Flow E 核心:按页组合生成 PPT
用法:
composer = PageComposer(theme="midnight")
composer.add_page("title_dark", {"title": "报告标题", "date": "2026-02"})
composer.add_page("kpi_cards", {"title": "核心指标", "cards": [...]})
composer.add_page("chart_full", {"title": "图表", "df": df, ...})
composer.save("output.pptx")
"""
import sys
from pathlib import Path
from pptx import Presentation
from pptx.util import Inches, Emu
from .layouts import LAYOUT_REGISTRY
from .themes import THEMES, DEFAULT_THEME, _LAYOUT_DEFAULTS
from .helpers import add_page_footer
# 不自动注入 header/footer 的布局
_SKIP_FOOTER_LAYOUTS = frozenset({
"title_dark", "title_light",
"conclusion_dark",
"section_divider",
})
class PageComposer:
"""页面级组合 PPT 生成器"""
def __init__(self, theme=None, slide_width=None, slide_height=None):
"""
Args:
theme: 主题名称(str) 或主题字典(dict)包含 slide_w/slide_h tokens
slide_width: 自定义幻灯片宽度优先级最高
slide_height: 自定义幻灯片高度优先级最高
"""
if isinstance(theme, str):
self.theme = THEMES.get(theme, DEFAULT_THEME)
elif isinstance(theme, dict):
merged = {**_LAYOUT_DEFAULTS, **DEFAULT_THEME, **theme}
self.theme = merged
else:
self.theme = DEFAULT_THEME
# 从 theme 读取尺寸slide_width/slide_height 参数可覆盖
_sw = slide_width or Inches(self.theme.get("slide_w", 13.333))
_sh = slide_height or Inches(self.theme.get("slide_h", 7.5))
self.prs = Presentation()
self.prs.slide_width = _sw
self.prs.slide_height = _sh
self._page_count = 0
@property
def available_layouts(self):
"""返回所有已注册的布局名称"""
return list(LAYOUT_REGISTRY.keys())
def add_page(self, layout_name: str, data: dict):
"""添加一页
Args:
layout_name: 布局名称必须在 LAYOUT_REGISTRY
data: 该布局所需的数据字典
可选字段 footnote (str): 数据来源/脚注自动传给 footer
可选字段 source (str): 兼容回退footnote 优先
Returns:
self (支持链式调用)
"""
if layout_name not in LAYOUT_REGISTRY:
available = ", ".join(LAYOUT_REGISTRY.keys())
raise ValueError(f"未知布局 '{layout_name}'。可用布局: {available}")
layout_fn = LAYOUT_REGISTRY[layout_name]
# 添加空白幻灯片
blank_layout = self._get_blank_layout()
slide = self.prs.slides.add_slide(blank_layout)
# 执行布局函数
layout_fn(slide, data, self.theme)
self._page_count += 1
# 自动注入 footer非封面/结论/分隔页)
if layout_name not in _SKIP_FOOTER_LAYOUTS:
footer_text = data.get("footnote", "") or data.get("source", "")
add_page_footer(
slide, self.theme,
source=footer_text,
page_num=self._page_count,
)
print(f" [+] Page {self._page_count}: {layout_name}", file=sys.stderr)
return self
def add_custom_page(self, render_fn, data=None):
"""添加自定义页面 — render_fn(slide, theme) 自行绘制
Args:
render_fn: callable(slide, theme) callable(slide, data, theme)
data: 传给 render_fn 的数据
"""
blank_layout = self._get_blank_layout()
slide = self.prs.slides.add_slide(blank_layout)
if data is not None:
render_fn(slide, data, self.theme)
else:
render_fn(slide, self.theme)
self._page_count += 1
print(f" [+] Page {self._page_count}: custom", file=sys.stderr)
return self
def save(self, path, lint=True):
"""保存 PPT
Args:
path: 输出文件路径
lint: 是否自动运行 DeckLinter默认 True
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
self.prs.save(str(path))
print(f"\n[OK] PPT saved: {path} ({self._page_count} pages)", file=sys.stderr)
# 自动运行 lint
if lint:
try:
from ppt_station.qa.deck_linter import DeckLinter
linter = DeckLinter(str(path))
report = linter.run()
linter.print_summary(report)
except ImportError:
pass # qa 模块尚未安装时静默跳过
except Exception as e:
print(f" [WARN] Lint skipped: {e}", file=sys.stderr)
return str(path)
def _get_blank_layout(self):
"""获取空白幻灯片布局"""
# 优先找名为 'Blank' 的布局
for layout in self.prs.slide_layouts:
if layout.name == "Blank":
return layout
# 否则用最后一个(通常是空白)
return self.prs.slide_layouts[-1]
@staticmethod
def register_layout(name: str, fn):
"""注册自定义布局函数
Args:
name: 布局名称
fn: callable(slide, data, theme)
"""
LAYOUT_REGISTRY[name] = fn