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>
161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
"""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
|