skills/ppt-station-skill/ppt_station/renderers/ppt_renderer.py

340 lines
12 KiB
Python
Raw Permalink Normal View History

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