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