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