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

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

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