skills/ppt-station-skill/ppt_station/engine.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

309 lines
12 KiB
Python
Raw Permalink 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.

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