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

340 lines
12 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.

"""
PPT 渲染器 - 从 JSON 重建 PPTX 文件
从解析器生成的 JSON 重建完整的 PPT
"""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.enum.text import PP_ALIGN
from pathlib import Path
from typing import Dict, List
import json
import pandas as pd
from ..chart_builder import create_combo_chart
from ..chart_builder.layout import ChartLayoutConfig, LegendConfig, ValueAxisConfig
from ..chart_builder.styles import StyleConfig
from ..chart_builder.date_axis import DateAxisConfig
from pptx.enum.chart import XL_LEGEND_POSITION
class PPTRenderer:
"""PPT 渲染器"""
def __init__(self, json_data: Dict):
"""
初始化渲染器
Args:
json_data: 解析器生成的 JSON 数据
"""
self.json_data = json_data
self.prs = Presentation()
# ⭐ 设置幻灯片尺寸为 16:9从元数据中读取
slide_width = json_data.get("metadata", {}).get("slide_width")
slide_height = json_data.get("metadata", {}).get("slide_height")
if slide_width and slide_height:
from pptx.util import Emu
self.prs.slide_width = Emu(slide_width)
self.prs.slide_height = Emu(slide_height)
print(f"✅ 幻灯片尺寸: {Emu(slide_width).inches:.2f}\" × {Emu(slide_height).inches:.2f}\"")
else:
# 默认使用 16:9
from pptx.util import Inches
self.prs.slide_width = Inches(13.33) # 16:9 标准宽度
self.prs.slide_height = Inches(7.5)
print(f"✅ 使用默认 16:9 尺寸")
def render(self) -> Presentation:
"""
从 JSON 渲染 PPT
Returns:
Presentation 对象
"""
for slide_data in self.json_data["slides"]:
self._render_slide(slide_data)
return self.prs
def _render_slide(self, slide_data: Dict):
"""渲染单个幻灯片"""
# 创建空白幻灯片
slide = self.prs.slides.add_slide(self.prs.slide_layouts[6]) # 空白布局
for element in slide_data["elements"]:
self._render_element(slide, element)
def _render_element(self, slide, element: Dict):
"""渲染单个元素"""
element_type = element.get("type")
position = element.get("position", {})
data = element.get("data", {})
if element_type == "chart":
self._render_chart(slide, data, position)
elif element_type == "table":
self._render_table(slide, data, position)
elif element_type == "textbox":
self._render_textbox(slide, data, position)
elif element_type == "group":
self._render_group(slide, data, position)
def _render_chart(self, slide, chart_data: Dict, position: Dict):
"""渲染图表"""
try:
# 重建 DataFrame
df = pd.DataFrame(chart_data["data"])
# 转换日期列
categories_col = chart_data["categories_col"]
if categories_col in df.columns:
df[categories_col] = pd.to_datetime(df[categories_col])
series_config = chart_data["series_config"]
layout_info = chart_data.get("layout_info", {})
# 提取数据范围信息
data_cols = [col for col in df.columns if col != categories_col]
# 配置样式
style_config = StyleConfig(
color_scheme="aim00",
line_width_pt=2.0,
marker_style="none",
)
# 配置布局
legend_pos_map = {
"-4160": XL_LEGEND_POSITION.TOP,
"-4107": XL_LEGEND_POSITION.BOTTOM,
"-4131": XL_LEGEND_POSITION.RIGHT,
"-4129": XL_LEGEND_POSITION.LEFT,
}
legend_pos_str = str(layout_info.get("legend", {}).get("position", -4160))
legend_position = legend_pos_map.get(legend_pos_str, XL_LEGEND_POSITION.TOP)
layout_config = ChartLayoutConfig(
title=chart_data.get("title", ""),
legend_config=LegendConfig(
position=legend_position,
font_size_pt=9,
font_name="黑体",
),
value_axis_config=ValueAxisConfig(
number_format="0%",
font_size_pt=9,
font_name="黑体",
has_major_gridlines=False,
),
secondary_value_axis_config=ValueAxisConfig(
number_format="#,##0",
font_size_pt=9,
font_name="黑体",
has_major_gridlines=False,
),
)
# 日期轴配置
label_interval = len(df) // 7
date_axis_config = DateAxisConfig(
base_unit='days',
major_unit=label_interval,
number_format='yyyy/mm',
)
layout_config.date_axis_config = date_axis_config
# 创建图表
chart = create_combo_chart(
slide=slide,
df=df,
categories_col=categories_col,
series_config=series_config,
position=(Emu(position["left"]), Emu(position["top"])),
size=(Emu(position["width"]), Emu(position["height"])),
style_config=style_config,
layout_config=layout_config,
)
print(f" ✅ 图表已渲染: {chart_data.get('title', '未命名')}")
except Exception as e:
print(f" ❌ 图表渲染失败: {e}")
import traceback
traceback.print_exc()
def _render_table(self, slide, table_data: Dict, position: Dict):
"""渲染表格"""
try:
rows = table_data["rows"]
cols = table_data["columns"]
data = table_data["data"]
# 创建表格
table_shape = slide.shapes.add_table(
rows, cols,
Emu(position["left"]),
Emu(position["top"]),
Emu(position["width"]),
Emu(position["height"])
)
table = table_shape.table
# 填充数据
for i, row_data in enumerate(data):
for j, cell_text in enumerate(row_data):
cell = table.cell(i, j)
cell.text = cell_text
# 设置字体
cell.text_frame.paragraphs[0].font.name = "黑体"
cell.text_frame.paragraphs[0].font.size = Pt(9)
print(f" ✅ 表格已渲染: {rows}× {cols}")
except Exception as e:
print(f" ❌ 表格渲染失败: {e}")
def _render_textbox(self, slide, textbox_data: Dict, position: Dict):
"""渲染文本框"""
try:
# 创建文本框
textbox = slide.shapes.add_textbox(
Emu(position["left"]),
Emu(position["top"]),
Emu(position["width"]),
Emu(position["height"])
)
text_frame = textbox.text_frame
# 添加段落
paragraphs = textbox_data.get("paragraphs", [])
for i, para_data in enumerate(paragraphs):
if i == 0:
# 使用默认的第一个段落
para = text_frame.paragraphs[0]
else:
para = text_frame.add_paragraph()
# 设置段落级别
if para_data.get("level"):
para.level = para_data["level"]
# 设置对齐方式
alignment_str = para_data.get("alignment")
if alignment_str:
try:
# 尝试解析对齐方式
if "CENTER" in alignment_str:
para.alignment = PP_ALIGN.CENTER
elif "RIGHT" in alignment_str:
para.alignment = PP_ALIGN.RIGHT
elif "LEFT" in alignment_str:
para.alignment = PP_ALIGN.LEFT
except:
pass
# 添加 runs保留原始格式
runs = para_data.get("runs", [])
if runs:
for run_data in runs:
run = para.add_run()
run.text = run_data["text"]
# ⭐ 应用原始字体信息
if run_data.get("font_name"):
# 如果原始字体不可用,回退到黑体
try:
run.font.name = run_data["font_name"]
except:
run.font.name = "黑体"
else:
run.font.name = "黑体"
if run_data.get("font_size"):
run.font.size = Pt(run_data["font_size"])
if run_data.get("bold"):
run.font.bold = True
if run_data.get("italic"):
run.font.italic = True
if run_data.get("underline"):
run.font.underline = True
# 应用颜色
if run_data.get("color"):
try:
from pptx.util import RGBColor
color_hex = run_data["color"].lstrip('#')
r = int(color_hex[0:2], 16)
g = int(color_hex[2:4], 16)
b = int(color_hex[4:6], 16)
run.font.color.rgb = RGBColor(r, g, b)
except:
pass
else:
# 如果没有 runs直接设置段落文本
para.text = para_data["text"]
para.font.name = "黑体"
para.font.size = Pt(9)
preview = textbox_data["full_text"][:30] + "..." if len(textbox_data["full_text"]) > 30 else textbox_data["full_text"]
print(f' ✅ 文本框已渲染: "{preview}"')
except Exception as e:
print(f" ❌ 文本框渲染失败: {e}")
import traceback
traceback.print_exc()
def _render_group(self, slide, group_data: Dict, position: Dict):
"""渲染组合形状(递归处理内部元素)"""
# 注意python-pptx 不直接支持创建组合形状
# 我们将组合形状的元素展开,直接渲染到幻灯片上
for element in group_data.get("elements", []):
self._render_element(slide, element)
print(f" ✅ 组合形状已渲染: {group_data.get('element_count', 0)} 个子元素")
def save(self, output_path: Path):
"""
保存 PPT 文件
Args:
output_path: 输出路径
"""
self.prs.save(str(output_path))
print(f"\n✅ PPT 已保存: {output_path}")
def render_from_json_file(json_path: Path, output_pptx_path: Path):
"""
从 JSON 文件渲染 PPT
Args:
json_path: JSON 文件路径
output_pptx_path: 输出的 PPTX 文件路径
"""
with open(json_path, 'r', encoding='utf-8') as f:
json_data = json.load(f)
renderer = PPTRenderer(json_data)
renderer.render()
renderer.save(output_pptx_path)
if __name__ == "__main__":
# 测试
from pathlib import Path
json_path = Path(__file__).parent.parent.parent / "output" / "aim01_parsed.json"
output_path = Path(__file__).parent.parent.parent / "output" / "aim01_rebuilt.pptx"
render_from_json_file(json_path, output_path)