""" PPT 渲染引擎 """ from pathlib import Path from typing import Dict from datetime import datetime import hashlib import json import pandas as pd from pptx import Presentation from ppt_station.models.job import Job from ppt_station.config import settings from ppt_station.connectors import ConnectorFactory from ppt_station.transformers import DataFrameTransformer from ppt_station.renderers import TextRenderer, TableRenderer from ppt_station.utils import get_slide_layouts from ppt_station.chart_builder import create_combo_chart class PptEngine: """PPT 渲染引擎""" def __init__(self): self.text_renderer = TextRenderer() self.table_renderer = TableRenderer() def render(self, job: Job) -> Path: """ 渲染 PPT Args: job: 任务配置 Returns: 生成的 PPT 文件路径 """ # 1. 加载数据(共用) raw_dfs = {} for name, datasource in job.datasources.items(): print(f"正在加载数据源: {name}") raw_dfs[name] = ConnectorFactory.load_data(name, datasource) # 2. 数据转换(共用) dfs = DataFrameTransformer.apply_transforms(raw_dfs, job.transforms or {}) # 3. 按模式分发 if job.mode == "composer": prs = self._render_composer(job, dfs) else: prs = self._render_template(job, dfs) # 4. 添加元数据 if job.output.add_metadata: self._add_metadata(prs, job) # 5. 保存文件 output_path = Path(job.output.path) if not output_path.is_absolute(): output_path = settings.output_dir / output_path output_path.parent.mkdir(parents=True, exist_ok=True) if output_path.exists() and not job.output.overwrite: raise FileExistsError(f"输出文件已存在: {output_path}") prs.save(str(output_path)) print(f"PPT 已保存: {output_path}") return output_path # ------------------------------------------------------------------ # Template 模式(原有逻辑,零修改搬入) # ------------------------------------------------------------------ def _render_template(self, job: Job, dfs: Dict[str, pd.DataFrame]) -> Presentation: """Template 模式渲染:基于模板 + slides 配置""" # 加载模板 template_path = Path(job.template.path) if not template_path.is_absolute(): template_path = settings.templates_dir / template_path if not template_path.exists(): raise FileNotFoundError(f"模板文件不存在: {template_path}") prs = Presentation(template_path) # 准备渲染上下文 context = {"params": job.params or {}} # 获取幻灯片版式 slide_layouts = get_slide_layouts(prs) # 渲染每一页 for i, slide_spec in enumerate(job.slides): print(f"正在渲染幻灯片: {slide_spec.id}") # 如果需要指定版式,则创建新幻灯片 if slide_spec.layout: if slide_spec.layout not in slide_layouts: raise ValueError(f"未找到版式: {slide_spec.layout}") slide = prs.slides.add_slide(slide_layouts[slide_spec.layout]) elif i < len(prs.slides): # 使用现有幻灯片 slide = prs.slides[i] else: # 使用默认版式 slide = prs.slides.add_slide(prs.slide_layouts[0]) # 渲染文本 if slide_spec.texts: self.text_renderer.render(slide, slide_spec.texts, context) # 渲染表格 if slide_spec.tables: for table_spec in slide_spec.tables: if table_spec.source not in dfs: print(f"警告: 数据源 '{table_spec.source}' 不存在") continue self.table_renderer.render(slide, table_spec, dfs[table_spec.source]) # 渲染图表 - 使用新的 chart_builder if slide_spec.charts: for chart_spec in slide_spec.charts: if chart_spec.source not in dfs: print(f"警告: 数据源 '{chart_spec.source}' 不存在") continue df = dfs[chart_spec.source] try: create_combo_chart( slide=slide, data=df, shape_name=chart_spec.shape_name if hasattr(chart_spec, 'shape_name') else None, ) except Exception as e: print(f"警告: 图表渲染失败: {e}") return prs # ------------------------------------------------------------------ # Composer 模式(新增) # ------------------------------------------------------------------ def _render_composer(self, job: Job, dfs: Dict[str, pd.DataFrame]) -> Presentation: """Composer 模式渲染:基于 PageComposer + pages 配置""" from ppt_station.composer import PageComposer from ppt_station.composer.themes import resolve_theme theme = resolve_theme(job.theme or "jp_finance", aspect_ratio=job.aspect_ratio) composer = PageComposer(theme=theme) default_lc = job.default_layout_config for page_spec in job.pages: data = self._resolve_data_refs(page_spec.data, dfs, default_layout_config=default_lc) composer.add_page(page_spec.layout, data) return composer.prs def _resolve_data_refs(self, data: dict, dfs: Dict[str, pd.DataFrame], default_layout_config=None) -> dict: """解析 data 中的 datasource/source 引用,替换为实际 DataFrame 语义规则: - datasource (新 canonical key): 始终视为数据源引用 - source: 值匹配已知数据源时视为引用并 pop;否则保留为脚注文本 - footnote: 始终保留,用于页脚渲染 执行顺序(修复嵌套 layout_config 类型错误): 1. 顶层 datasource 解析 2. 嵌套 datasource 解析(不转 config 对象) 3. 注入 defaults(全部仍为 dict,安全合并) 4. 统一转对象(顶层 + 嵌套) """ resolved = dict(data) # Step 1: 顶层 datasource 解析 ds_key = None if "datasource" in resolved: ds_key = "datasource" elif "source" in resolved and resolved["source"] in dfs: ds_key = "source" if ds_key: resolved["df"] = dfs[resolved.pop(ds_key)] # Step 2: 嵌套 datasource 解析(不转 config 对象) for key in ("left", "right", "top", "bottom"): if key in resolved and isinstance(resolved[key], dict): sub = dict(resolved[key]) sub_ds_key = None if "datasource" in sub: sub_ds_key = "datasource" elif "source" in sub and sub["source"] in dfs: sub_ds_key = "source" if sub_ds_key: sub["df"] = dfs[sub.pop(sub_ds_key)] resolved[key] = sub # Step 3: 注入 defaults(全部仍为 dict,安全合并) if default_layout_config: self._inject_default_layout_config(resolved, default_layout_config) # Step 4: 统一转对象(顶层 + 嵌套) self._resolve_config_objects(resolved) for key in ("left", "right", "top", "bottom"): if key in resolved and isinstance(resolved[key], dict): self._resolve_config_objects(resolved[key]) return resolved def _inject_default_layout_config(self, data: dict, defaults: dict) -> None: """将 Job 级 default_layout_config 合并到页面数据中(页面级优先覆盖)""" def _merge_lc(target): if "layout_config" not in target and "df" not in target: return # 非图表数据,跳过 if "layout_config" in target: merged = dict(defaults) merged.update(target["layout_config"]) # 页面级覆盖 target["layout_config"] = merged elif "df" in target: target["layout_config"] = dict(defaults) _merge_lc(data) for key in ("left", "right", "top", "bottom"): if key in data and isinstance(data[key], dict): _merge_lc(data[key]) def _resolve_config_objects(self, data: dict) -> None: """将 dict 形式的 style_config / layout_config 转为对象""" from ppt_station.chart_builder.styles import StyleConfig from ppt_station.chart_builder.layout import ( ChartLayoutConfig, LegendConfig, ValueAxisConfig, CategoryAxisConfig, ) if "style_config" in data and isinstance(data["style_config"], dict): data["style_config"] = StyleConfig(**data["style_config"]) if "layout_config" in data and isinstance(data["layout_config"], dict): data["layout_config"] = self._build_layout_config(data["layout_config"]) def _build_layout_config(self, cfg: dict): """将嵌套 dict 转为 ChartLayoutConfig,处理子对象实例化""" from ppt_station.chart_builder.layout import ( ChartLayoutConfig, LegendConfig, ValueAxisConfig, CategoryAxisConfig, ) kwargs = dict(cfg) if "legend_config" in kwargs and isinstance(kwargs["legend_config"], dict): kwargs["legend_config"] = LegendConfig(**kwargs["legend_config"]) if "category_axis_config" in kwargs and isinstance(kwargs["category_axis_config"], dict): kwargs["category_axis_config"] = CategoryAxisConfig(**kwargs["category_axis_config"]) if "value_axis_config" in kwargs and isinstance(kwargs["value_axis_config"], dict): kwargs["value_axis_config"] = ValueAxisConfig(**kwargs["value_axis_config"]) if "secondary_value_axis_config" in kwargs and isinstance(kwargs["secondary_value_axis_config"], dict): kwargs["secondary_value_axis_config"] = ValueAxisConfig(**kwargs["secondary_value_axis_config"]) # date_axis_config 用预设名解析 if "date_axis_config" in kwargs and isinstance(kwargs["date_axis_config"], str): from ppt_station.chart_builder.date_axis import ( DAILY_TICKS, WEEKLY_TICKS, BIWEEKLY_TICKS, MONTHLY_TICKS, QUARTERLY_TICKS, YEARLY_TICKS, ) presets = { "daily": DAILY_TICKS, "weekly": WEEKLY_TICKS, "biweekly": BIWEEKLY_TICKS, "monthly": MONTHLY_TICKS, "quarterly": QUARTERLY_TICKS, "yearly": YEARLY_TICKS, } preset_name = kwargs["date_axis_config"].lower() if preset_name in presets: kwargs["date_axis_config"] = presets[preset_name] else: raise ValueError(f"未知日期轴预设: {preset_name}。可用: {list(presets.keys())}") return ChartLayoutConfig(**kwargs) # ------------------------------------------------------------------ # 共用工具 # ------------------------------------------------------------------ def _add_metadata(self, prs: Presentation, job: Job) -> None: """添加生成信息到 PPT 元数据""" core_props = prs.core_properties core_props.modified = datetime.now() metadata_lines = [ "生成信息:", f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", ] if job.mode == "template" and job.template: metadata_lines.append(f"模板: {job.template.path}") elif job.mode == "composer": metadata_lines.append(f"模式: composer, 主题: {job.theme or 'jp_finance'}") if job.params: metadata_lines.append(f"参数: {json.dumps(job.params, ensure_ascii=False)}") config_str = json.dumps(job.model_dump(), ensure_ascii=False, sort_keys=True, default=str) config_hash = hashlib.sha256(config_str.encode()).hexdigest()[:16] metadata_lines.append(f"配置哈希: {config_hash}") core_props.comments = "\n".join(metadata_lines)