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