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>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""
|
||
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)
|