1871 lines
81 KiB
Python
1871 lines
81 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
aura-ppt 渲染引擎
|
||
综合 AI PPT 工具优点:Gamma 设计感 / WPS 中文排版 / 7牛 内容保真 / Beautiful.ai 无损导出
|
||
用法:
|
||
python engine.py --plan plan.json --output out.pptx [--design aura-dark]
|
||
python engine.py --improve input.pptx --output out.pptx [--design aura-dark]
|
||
python engine.py --verify output.pptx [--plan plan.json]
|
||
"""
|
||
import argparse
|
||
import copy
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import sys
|
||
import csv
|
||
from pathlib import Path
|
||
|
||
try:
|
||
from pptx import Presentation
|
||
from pptx.util import Inches, Pt, Emu
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||
from pptx.enum.shapes import MSO_SHAPE
|
||
from pptx.oxml.ns import qn
|
||
except ImportError:
|
||
sys.exit("[aura-ppt] 缺少依赖: pip install python-pptx")
|
||
|
||
# ---------------------------------------------------------------- 设计系统
|
||
class DesignSystem:
|
||
"""aura-dark: 品牌深色设计系统(默认,深度定制)"""
|
||
|
||
def __init__(self):
|
||
self.name = "aura-dark"
|
||
self.dark = True # 是否为深色系
|
||
self.glow = True # 是否绘制左上光晕装饰
|
||
self.bg = RGBColor(0x0B, 0x0E, 0x14)
|
||
self.bg2 = RGBColor(0x11, 0x15, 0x1F)
|
||
self.panel = RGBColor(0x17, 0x1D, 0x2B)
|
||
self.panel2 = RGBColor(0x12, 0x17, 0x22)
|
||
self.line = RGBColor(0x27, 0x30, 0x42)
|
||
self.text = RGBColor(0xF0, 0xF2, 0xF5)
|
||
self.text_dim = RGBColor(0x8A, 0x93, 0xA3)
|
||
self.primary = RGBColor(0x2E, 0xCC, 0xFF) # 电光青
|
||
self.accent = RGBColor(0xFF, 0xC9, 0x4D) # 琥珀金
|
||
self.secondary = RGBColor(0xA7, 0x8B, 0xFA) # 霓虹紫
|
||
self.success = RGBColor(0x3D, 0xD6, 0x8C)
|
||
self.danger = RGBColor(0xFF, 0x6B, 0x6B)
|
||
# 卡片填充(深色系统的流程图卡片色)
|
||
self.card_fill = RGBColor(0x1F, 0x2A, 0x3D)
|
||
# 图表色板(协调渐变色系:青→蓝→紫→绿→橙→金)
|
||
self.chart_colors = [
|
||
RGBColor(0x2E, 0xCC, 0xFF), RGBColor(0x5C, 0x9D, 0xFF),
|
||
RGBColor(0xA7, 0x8B, 0xFA), RGBColor(0x3D, 0xD6, 0x8C),
|
||
RGBColor(0xFF, 0x8A, 0x65), RGBColor(0xFF, 0xC9, 0x4D),
|
||
]
|
||
# 字体回退链:中 → 日 → 英
|
||
self.font_cn = "Microsoft YaHei"
|
||
self.font_jp = "Yu Gothic"
|
||
self.font_en = "Segoe UI"
|
||
self.margin = Inches(0.55)
|
||
self.page_w = Inches(13.333)
|
||
self.page_h = Inches(7.5)
|
||
# 区域布局(inches,相对页面)
|
||
# 模板对齐模式:布局坐标与公司PPT母版一致
|
||
self.layout = {
|
||
"header_top": 0.46, # 页眉标题顶部
|
||
"header_h": 0.65, # 页眉标题高度
|
||
"subtitle_top": 1.05, # 副标题顶部
|
||
"divider_y": 1.48, # 分隔线y
|
||
"body_top": 1.85, # 正文内容顶部
|
||
"body_bottom": 6.35, # 正文内容底部(footer上方)
|
||
"footer_top": 7.08, # 页脚文字顶部
|
||
"page_num_y": 7.16, # 页码顶部
|
||
"page_num_x": 0.9, # 页码距右侧距离
|
||
}
|
||
# 统一排版层级(字号语义化,保证一致性)
|
||
self.typography = {
|
||
"cover_title": 38, # 封面主标题
|
||
"cover_subtitle": 18, # 封面副标题
|
||
"cover_body": 15, # 封面内容
|
||
"section_title": 34, # 章节页标题
|
||
"section_kicker": 14, # 章节页kicker
|
||
"page_title": 22, # 页面标题
|
||
"page_subtitle": 12, # 页面副标题
|
||
"card_title": 16, # 卡片标题
|
||
"body": 15, # 正文
|
||
"body_small": 13, # 小正文(双栏/卡片内)
|
||
"table_head": 13, # 表头
|
||
"table_cell": 12, # 表格单元格
|
||
"chart_label": 12, # 图表数值标签
|
||
"chart_cat": 9, # 图表类别标签
|
||
"timeline_title": 13, # 时间线阶段标题
|
||
"timeline_desc": 10, # 时间线描述
|
||
"footer": 9, # 页脚
|
||
"notes": 10, # 备注/辅助
|
||
}
|
||
|
||
def font_for(self, text):
|
||
"""统一字体:默认全部使用中文字体(微软雅黑,含拉丁字形,混排统一)
|
||
report-jp 日文场景使用日文字体"""
|
||
has_jp = re.search(r'[\u3040-\u30ff\u31f0-\u31ff]', text)
|
||
if self.name == "report-jp" and has_jp:
|
||
return self.font_jp
|
||
return self.font_cn
|
||
|
||
|
||
class BusinessReport(DesignSystem):
|
||
"""business-report: 商务报告风格(浅色 · 咨询报告质感)
|
||
灵感来源:麦肯锡/四大咨询报告、PowerPoint 商务模板。
|
||
特点:白色底、火焰蓝主色、商务红强调、清晰表格、克制的信息层次。
|
||
"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.name = "business-report"
|
||
self.dark = False
|
||
self.glow = False # 浅色风格不画光晕
|
||
self.bg = RGBColor(0xFF, 0xFF, 0xFF) # 纯白底
|
||
self.bg2 = RGBColor(0xF5, 0xF6, 0xFA) # 极浅灰(交替行)
|
||
self.panel = RGBColor(0xF4, 0xF6, 0xFA) # 浅灰面板
|
||
self.panel2 = RGBColor(0xEC, 0xEF, 0xF5) # 面板暗
|
||
self.line = RGBColor(0xD8, 0xDE, 0xE9) # 浅灰分隔线
|
||
self.text = RGBColor(0x1F, 0x29, 0x37) # 深灰文本
|
||
self.text_dim = RGBColor(0x6B, 0x72, 0x80) # 辅助灰
|
||
self.primary = RGBColor(0x1E, 0x50, 0xA2) # 火焰蓝(主色)
|
||
self.accent = RGBColor(0xC0, 0x00, 0x00) # 商务红(强调)
|
||
self.secondary = RGBColor(0x3B, 0x78, 0xC4) # 亮蓝(次级)
|
||
self.success = RGBColor(0x22, 0x8B, 0x22) # 墨绿
|
||
self.danger = RGBColor(0xC0, 0x00, 0x00) # 红
|
||
# 卡片填充(浅蓝灰,用于流程图卡片)
|
||
self.card_fill = RGBColor(0xE8, 0xEE, 0xF7)
|
||
# 图表色板(火焰蓝系列)
|
||
self.chart_colors = [
|
||
RGBColor(0x1E, 0x50, 0xA2), RGBColor(0x3B, 0x78, 0xC4),
|
||
RGBColor(0xC0, 0x00, 0x00), RGBColor(0xED, 0x7D, 0x31),
|
||
RGBColor(0x70, 0xAD, 0x47), RGBColor(0x70, 0x30, 0xA0),
|
||
]
|
||
|
||
|
||
class ReportJP(DesignSystem):
|
||
"""report-jp: 日式商务报告风格
|
||
参考:日本本部报告会资料(法人蓝 #0B3D91 + 浅天蓝卡片 #B7DEE8 + Meiryo UI)。
|
||
特点:非标准页面比例(10.83x7.5in)、章节编号页眉、机密标记、卡片+流程图布局。
|
||
"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.name = "report-jp"
|
||
self.dark = False
|
||
self.glow = False
|
||
self.page_w = Inches(10.83) # 参考PPT比例
|
||
self.page_h = Inches(7.5)
|
||
self.bg = RGBColor(0xFF, 0xFF, 0xFF) # 纯白底
|
||
self.bg2 = RGBColor(0xF5, 0xF7, 0xFB) # 极浅蓝灰(交替行)
|
||
self.panel = RGBColor(0xE8, 0xEA, 0xF6) # 浅蓝灰面板
|
||
self.panel2 = RGBColor(0xDD, 0xE3, 0xF2) # 面板暗
|
||
self.line = RGBColor(0xC9, 0xD4, 0xE8) # 浅蓝分隔线
|
||
self.text = RGBColor(0x1A, 0x1A, 0x1A) # 黑色正文(日企惯例)
|
||
self.text_dim = RGBColor(0x55, 0x60, 0x70) # 辅助灰
|
||
self.primary = RGBColor(0x0B, 0x3D, 0x91) # 法人蓝(主色)
|
||
self.accent = RGBColor(0xC0, 0x00, 0x00) # 强调红(警示)
|
||
self.secondary = RGBColor(0x19, 0x76, 0xD2) # Material蓝(次级)
|
||
self.success = RGBColor(0x2E, 0x7D, 0x32) # 绿
|
||
self.danger = RGBColor(0xC0, 0x00, 0x00) # 红
|
||
# 卡片填充浅天蓝(参考PPT主填充)
|
||
self.card_fill = RGBColor(0xB7, 0xDE, 0xE8)
|
||
# 图表色板(协调渐变色系:蓝→青→绿→金→橙,冷暖过渡)
|
||
self.chart_colors = [
|
||
RGBColor(0x0B, 0x3D, 0x91), # 深法人蓝(主)
|
||
RGBColor(0x2E, 0x74, 0xB5), # 中蓝
|
||
RGBColor(0x00, 0x99, 0xCC), # 青蓝
|
||
RGBColor(0x26, 0xA6, 0x9A), # 青绿
|
||
RGBColor(0xF2, 0xB1, 0x34), # 琥珀金
|
||
RGBColor(0xEF, 0x6C, 0x00), # 橙
|
||
]
|
||
# 字体:日企标准 Meiryo UI(对日业务核心字体)
|
||
self.font_cn = "Microsoft YaHei"
|
||
self.font_jp = "Meiryo UI"
|
||
self.font_en = "Segoe UI"
|
||
# 区域布局:与公司PPT母版对齐(参考 010_【本部長報告会】)
|
||
# 模板规格:章节行 y0.32(20pt) / 主题行 y0.88(16pt) / 卡片标题 y1.74
|
||
# 页脚条 y6.4~7.1 / 页码 y7.16 右下角 / 安全区 x0.38~10.61
|
||
self.layout = {
|
||
"header_top": 0.32, # 章节行顶部(模板 y0.32,20pt)
|
||
"header_h": 0.45, # 章节行高度
|
||
"subtitle_top": 0.88, # 主题行顶部(模板 y0.88,16pt)
|
||
"divider_y": 1.55, # 分隔线y(不用,模板无分隔线)
|
||
"body_top": 1.60, # 正文内容顶部(模板卡片标题 y1.74 上方留白)
|
||
"body_bottom": 6.30, # 正文内容底部(模板页脚条 y6.4)
|
||
"footer_top": 7.05, # 页脚文字顶部
|
||
"page_num_y": 7.16, # 页码顶部(模板页码 y7.16~7.57)
|
||
"page_num_x": 0.57, # 页码距右侧距离(模板页码 x10.26)
|
||
}
|
||
|
||
def font_for(self, text):
|
||
"""日式报告统一 Meiryo UI(含拉丁字形,日中文混排统一)"""
|
||
return self.font_jp
|
||
|
||
|
||
# ----------------------------------------------------------------
|
||
# 项目五色板设计系统(2026-08-24 PPT自动生成Agent 移植扩展)
|
||
# 继承 business-report 浅色骨架,按产品五色板覆写主次色/卡片/图表色
|
||
# 对应 PRD US-2.5:blue/green/purple/orange/gray
|
||
# ----------------------------------------------------------------
|
||
class _CorpPalette(BusinessReport):
|
||
"""五色板公共骨架:浅色商务风,子类只换颜色令牌"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.name = self._palette_name()
|
||
self._apply_palette()
|
||
|
||
def _palette_name(self):
|
||
raise NotImplementedError
|
||
|
||
def _apply_palette(self):
|
||
raise NotImplementedError
|
||
|
||
|
||
class CorpBlue(_CorpPalette):
|
||
"""corp-blue: 商务蓝(产品默认配色,对应品牌色 #1565C0/#2196F3)"""
|
||
|
||
def _palette_name(self):
|
||
return "corp-blue"
|
||
|
||
def _apply_palette(self):
|
||
self.primary = RGBColor(0x15, 0x65, 0xC0)
|
||
self.secondary = RGBColor(0x21, 0x96, 0xF3)
|
||
self.card_fill = RGBColor(0xE3, 0xF2, 0xFD)
|
||
self.panel = RGBColor(0xF0, 0xF6, 0xFC)
|
||
self.panel2 = RGBColor(0xE3, 0xF2, 0xFD)
|
||
self.chart_colors = [
|
||
RGBColor(0x15, 0x65, 0xC0), RGBColor(0x21, 0x96, 0xF3),
|
||
RGBColor(0x26, 0xC6, 0xDA), RGBColor(0x66, 0xBB, 0x6A),
|
||
RGBColor(0xFF, 0xA7, 0x26), RGBColor(0xAB, 0x47, 0xBC),
|
||
]
|
||
|
||
|
||
class CorpGreen(_CorpPalette):
|
||
"""corp-green: 清新绿(#34A853/#81C995)"""
|
||
|
||
def _palette_name(self):
|
||
return "corp-green"
|
||
|
||
def _apply_palette(self):
|
||
self.primary = RGBColor(0x2E, 0x7D, 0x32)
|
||
self.secondary = RGBColor(0x34, 0xA8, 0x53)
|
||
self.card_fill = RGBColor(0xE8, 0xF5, 0xE9)
|
||
self.panel = RGBColor(0xF1, 0xF8, 0xF2)
|
||
self.panel2 = RGBColor(0xE8, 0xF5, 0xE9)
|
||
self.chart_colors = [
|
||
RGBColor(0x2E, 0x7D, 0x32), RGBColor(0x34, 0xA8, 0x53),
|
||
RGBColor(0x81, 0xC9, 0x95), RGBColor(0x00, 0x89, 0x7B),
|
||
RGBColor(0xF9, 0xA8, 0x25), RGBColor(0x5C, 0x6B, 0xC0),
|
||
]
|
||
|
||
|
||
class CorpPurple(_CorpPalette):
|
||
"""corp-purple: 科技紫(#7C3AED/#A78BFA)"""
|
||
|
||
def _palette_name(self):
|
||
return "corp-purple"
|
||
|
||
def _apply_palette(self):
|
||
self.primary = RGBColor(0x5B, 0x21, 0xB6)
|
||
self.secondary = RGBColor(0x7C, 0x3A, 0xED)
|
||
self.card_fill = RGBColor(0xED, 0xE9, 0xFE)
|
||
self.panel = RGBColor(0xF5, 0xF3, 0xFF)
|
||
self.panel2 = RGBColor(0xED, 0xE9, 0xFE)
|
||
self.chart_colors = [
|
||
RGBColor(0x5B, 0x21, 0xB6), RGBColor(0x7C, 0x3A, 0xED),
|
||
RGBColor(0xA7, 0x8B, 0xFA), RGBColor(0xEC, 0x48, 0x99),
|
||
RGBColor(0xF5, 0x9E, 0x0B), RGBColor(0x0E, 0xA5, 0xE9),
|
||
]
|
||
|
||
|
||
class CorpOrange(_CorpPalette):
|
||
"""corp-orange: 活力橙(#F59E0B/#FBBF24)"""
|
||
|
||
def _palette_name(self):
|
||
return "corp-orange"
|
||
|
||
def _apply_palette(self):
|
||
self.primary = RGBColor(0xD9, 0x77, 0x06)
|
||
self.secondary = RGBColor(0xF5, 0x9E, 0x0B)
|
||
self.card_fill = RGBColor(0xFE, 0xF3, 0xC7)
|
||
self.panel = RGBColor(0xFF, 0xFB, 0xEB)
|
||
self.panel2 = RGBColor(0xFE, 0xF3, 0xC7)
|
||
self.chart_colors = [
|
||
RGBColor(0xD9, 0x77, 0x06), RGBColor(0xF5, 0x9E, 0x0B),
|
||
RGBColor(0xFB, 0xBF, 0x24), RGBColor(0xEF, 0x44, 0x44),
|
||
RGBColor(0x10, 0xB9, 0x81), RGBColor(0x63, 0x66, 0xF1),
|
||
]
|
||
|
||
|
||
class CorpGray(_CorpPalette):
|
||
"""corp-gray: 深灰稳重(#374151/#6B7280),图表以灰阶+蓝红点睛区分系列"""
|
||
|
||
def _palette_name(self):
|
||
return "corp-gray"
|
||
|
||
def _apply_palette(self):
|
||
self.primary = RGBColor(0x37, 0x41, 0x51)
|
||
self.secondary = RGBColor(0x6B, 0x72, 0x80)
|
||
self.card_fill = RGBColor(0xF3, 0xF4, 0xF6)
|
||
self.panel = RGBColor(0xF5, 0xF6, 0xFA)
|
||
self.panel2 = RGBColor(0xE5, 0xE7, 0xEB)
|
||
self.accent = RGBColor(0xC0, 0x00, 0x00)
|
||
self.chart_colors = [
|
||
RGBColor(0x1F, 0x29, 0x37), RGBColor(0x4B, 0x55, 0x63),
|
||
RGBColor(0x9C, 0xA3, 0xAF), RGBColor(0x15, 0x65, 0xC0),
|
||
RGBColor(0xC0, 0x00, 0x00), RGBColor(0x0E, 0xA5, 0xE9),
|
||
]
|
||
|
||
|
||
# 设计系统注册表
|
||
DESIGN_SYSTEMS = {
|
||
"aura-dark": DesignSystem,
|
||
"business-report": BusinessReport,
|
||
"report-jp": ReportJP,
|
||
"corp-blue": CorpBlue,
|
||
"corp-green": CorpGreen,
|
||
"corp-purple": CorpPurple,
|
||
"corp-orange": CorpOrange,
|
||
"corp-gray": CorpGray,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------- 工具函数
|
||
def set_run_font(run, size, bold, color, font_name):
|
||
run.font.size = Pt(size)
|
||
run.font.bold = bold
|
||
run.font.color.rgb = color
|
||
run.font.name = font_name
|
||
rPr = run._r.get_or_add_rPr()
|
||
for tag in ('a:ea', 'a:cs'):
|
||
el = rPr.find(qn(tag))
|
||
if el is None:
|
||
el = rPr.makeelement(qn(tag), {})
|
||
rPr.append(el)
|
||
el.set('typeface', font_name)
|
||
|
||
|
||
def add_textbox(slide, x, y, w, h, ds, anchor=MSO_ANCHOR.TOP):
|
||
tb = slide.shapes.add_textbox(x, y, w, h)
|
||
tf = tb.text_frame
|
||
tf.word_wrap = True
|
||
tf.vertical_anchor = anchor
|
||
tf.margin_left = Pt(4)
|
||
tf.margin_right = Pt(4)
|
||
tf.margin_top = Pt(2)
|
||
tf.margin_bottom = Pt(2)
|
||
return tb, tf
|
||
|
||
|
||
def add_text(slide, x, y, w, h, text, ds, size=16, bold=False,
|
||
color=None, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
|
||
line_spacing=1.25, space_after=6):
|
||
"""支持 **加粗** 与 - 子项标记"""
|
||
if color is None:
|
||
color = ds.text
|
||
tb, tf = add_textbox(slide, x, y, w, h, ds, anchor)
|
||
lines = str(text).split('\n')
|
||
for i, line in enumerate(lines):
|
||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||
p.alignment = align
|
||
p.line_spacing = line_spacing
|
||
p.space_after = Pt(space_after)
|
||
# 解析 **bold** 与 - 子项
|
||
is_sub = line.strip().startswith('-')
|
||
content = line.strip()[1:].strip() if is_sub else line
|
||
indent = 0 if not is_sub else 1
|
||
if indent:
|
||
p.level = 1
|
||
parts = re.split(r'(\*\*.*?\*\*)', content)
|
||
for part in parts:
|
||
if not part:
|
||
continue
|
||
r = p.add_run()
|
||
if part.startswith('**') and part.endswith('**'):
|
||
r.text = part[2:-2]
|
||
set_run_font(r, size, True, color or ds.text, ds.font_for(part))
|
||
else:
|
||
r.text = part
|
||
set_run_font(r, size, bold, color or ds.text, ds.font_for(part))
|
||
return tb
|
||
|
||
|
||
def add_rect(slide, x, y, w, h, ds, fill=None, line=None, radius=False,
|
||
line_w=1.0, shadow=False):
|
||
st = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
|
||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||
if radius:
|
||
try:
|
||
shp.adjustments[0] = 0.08
|
||
except Exception:
|
||
pass
|
||
if fill is None:
|
||
shp.fill.background()
|
||
else:
|
||
shp.fill.solid()
|
||
shp.fill.fore_color.rgb = fill
|
||
if line is None:
|
||
shp.line.fill.background()
|
||
else:
|
||
shp.line.color.rgb = line
|
||
shp.line.width = Pt(line_w)
|
||
shp.shadow.inherit = False
|
||
if shadow:
|
||
add_shape_shadow(shp, ds)
|
||
tf = shp.text_frame
|
||
tf.word_wrap = True
|
||
tf.margin_left = Pt(8)
|
||
tf.margin_right = Pt(8)
|
||
tf.margin_top = Pt(4)
|
||
tf.margin_bottom = Pt(4)
|
||
return shp
|
||
|
||
|
||
def add_shape_shadow(shp, ds, blur=90000, dist=45000, alpha=25000):
|
||
"""给形状添加柔和外投影(XML 层操作)"""
|
||
import lxml.etree as etree
|
||
spPr = shp._element.spPr
|
||
# 移除已有 effectLst
|
||
for el in spPr.findall(qn('a:effectLst')):
|
||
spPr.remove(el)
|
||
effect = etree.SubElement(spPr, qn('a:effectLst'))
|
||
outer = etree.SubElement(effect, qn('a:outerShdw'))
|
||
outer.set('blurRad', str(blur))
|
||
outer.set('dist', str(dist))
|
||
outer.set('dir', '5400000')
|
||
outer.set('rotWithShape', '0')
|
||
srgb = etree.SubElement(outer, qn('a:srgbClr'))
|
||
srgb.set('val', '1F2937')
|
||
alpha_el = etree.SubElement(srgb, qn('a:alpha'))
|
||
alpha_el.set('val', str(alpha))
|
||
|
||
|
||
def add_gradient_rect(slide, x, y, w, h, ds, color_top, color_bottom,
|
||
radius=False, angle=90):
|
||
"""垂直渐变填充矩形(顶部亮色 → 底部深色,模拟立体柱体)"""
|
||
st = MSO_SHAPE.ROUNDED_RECTANGLE if radius else MSO_SHAPE.RECTANGLE
|
||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||
if radius:
|
||
try:
|
||
shp.adjustments[0] = 0.12
|
||
except Exception:
|
||
pass
|
||
shp.shadow.inherit = False
|
||
# 设置渐变填充(通过 XML:gradFill)
|
||
spPr = shp._element.spPr
|
||
# 移除已有填充
|
||
for tag in ('a:noFill', 'a:solidFill', 'a:gradFill', 'a:pattFill'):
|
||
el = spPr.find(qn(tag))
|
||
if el is not None:
|
||
spPr.remove(el)
|
||
grad = spPr.makeelement(qn('a:gradFill'), {})
|
||
gsLst = spPr.makeelement(qn('a:gsLst'), {})
|
||
# 渐变停止点:顶部亮色(0%) → 底部深色(100%)
|
||
stops = [
|
||
(0, color_top),
|
||
(100, color_bottom),
|
||
]
|
||
for pos, color in stops:
|
||
gs = spPr.makeelement(qn('a:gs'), {'pos': str(pos)})
|
||
srgb = spPr.makeelement(qn('a:srgbClr'), {'val': '%02X%02X%02X' % (
|
||
color[0], color[1], color[2])})
|
||
gs.append(srgb)
|
||
gsLst.append(gs)
|
||
grad.append(gsLst)
|
||
# 线性渐变方向
|
||
lin = spPr.makeelement(qn('a:lin'), {
|
||
'ang': str(angle * 60000), 'scaled': '1'})
|
||
grad.append(lin)
|
||
# 插入到 spPr(fill 应位于 line 之前)
|
||
line_el = spPr.find(qn('a:ln'))
|
||
if line_el is not None:
|
||
line_el.addprevious(grad)
|
||
else:
|
||
spPr.append(grad)
|
||
# 无边框
|
||
shp.line.fill.background()
|
||
tf = shp.text_frame
|
||
tf.word_wrap = True
|
||
return shp
|
||
|
||
|
||
def add_shape(slide, st, x, y, w, h, ds, fill=None, line=None):
|
||
shp = slide.shapes.add_shape(st, x, y, w, h)
|
||
if fill is None:
|
||
shp.fill.background()
|
||
else:
|
||
shp.fill.solid()
|
||
shp.fill.fore_color.rgb = fill
|
||
if line is None:
|
||
shp.line.fill.background()
|
||
else:
|
||
shp.line.color.rgb = line
|
||
shp.line.width = Pt(1.0)
|
||
shp.shadow.inherit = False
|
||
return shp
|
||
|
||
|
||
def pill(slide, x, y, w, h, text, ds, fill, text_color=None, size=12):
|
||
p = add_rect(slide, x, y, w, h, ds, fill=fill, radius=True)
|
||
tf = p.text_frame
|
||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
p0 = tf.paragraphs[0]
|
||
p0.alignment = PP_ALIGN.CENTER
|
||
r = p0.add_run()
|
||
r.text = text
|
||
# 深色系: pill 上文字用底色(深色);浅色系: pill 上文字用白色
|
||
if text_color is None:
|
||
text_color = ds.bg if ds.dark else RGBColor(0xFF, 0xFF, 0xFF)
|
||
set_run_font(r, size, True, text_color, ds.font_for(text))
|
||
return p
|
||
|
||
|
||
# ---------------------------------------------------------------- 页面渲染器
|
||
class SlideRenderer:
|
||
def __init__(self, prs, ds, template=False):
|
||
self.prs = prs
|
||
self.ds = ds
|
||
self.template = template
|
||
# 使用模板时选择空白版式(L6 白紙);无模板时用 blank index 6
|
||
self.blank = prs.slide_layouts[6]
|
||
self.margin = ds.margin
|
||
self.content_w = Emu(int(ds.page_w - 2 * ds.margin))
|
||
# 统一字号体系(设计系统 typography)
|
||
self.ty = getattr(ds, "typography", {})
|
||
|
||
def tp(self, key, default=14):
|
||
"""获取统一排版字号"""
|
||
return self.ty.get(key, default)
|
||
|
||
def new(self):
|
||
s = self.prs.slides.add_slide(self.blank)
|
||
if not self.template:
|
||
# 非模板模式:绘制纯色背景(模板模式继承母版背景)
|
||
add_rect(s, 0, 0, self.ds.page_w, self.ds.page_h, self.ds,
|
||
fill=self.ds.bg)
|
||
# 左上光晕装饰(仅深色系)
|
||
if self.ds.glow:
|
||
for i in range(5, 0, -1):
|
||
add_shape(s, MSO_SHAPE.OVAL,
|
||
Inches(-1.0 + i * 0.05), Inches(-1.3 + i * 0.05),
|
||
Inches(2.6 - i * 0.1), Inches(2.6 - i * 0.1),
|
||
self.ds, fill=None, line=self.ds.primary)
|
||
elif self.ds.name == "report-jp":
|
||
# report-jp 模板模式:内容区极浅蓝灰背景(视觉分区,区别于纯白母版)
|
||
lay = self.ds.layout
|
||
bg_top = Inches(lay["body_top"] - 0.15)
|
||
bg_h = Emu(int(Inches(lay["body_bottom"] + 0.15) - bg_top))
|
||
add_rect(s, self.margin, bg_top, self.content_w, bg_h,
|
||
self.ds, fill=RGBColor(0xF7, 0xF9, 0xFC))
|
||
return s
|
||
|
||
def header(self, s, title, subtitle=None, page=None, total=None,
|
||
section_no=None, confidential=None):
|
||
"""页眉:设计系统 layout 坐标。
|
||
report-jp 采用模板风格:章节行20pt在上(y0.32) + 主题行在下(y0.88)
|
||
"""
|
||
lay = self.ds.layout
|
||
# 模板风格:章节行(编号+标题)在顶部小字
|
||
if self.ds.name == "report-jp":
|
||
self._header_jp(s, title, subtitle, section_no, confidential)
|
||
return
|
||
header_top = Inches(lay["header_top"])
|
||
header_h = Inches(lay["header_h"])
|
||
sub_top = Inches(lay["subtitle_top"])
|
||
div_y = Inches(lay["divider_y"])
|
||
add_rect(s, self.margin, header_top, Inches(0.07), header_h,
|
||
self.ds, fill=self.ds.primary)
|
||
# 日式页眉:章节编号 + 标题
|
||
if section_no:
|
||
add_text(s, Inches(0.2), header_top, Inches(0.4), header_h,
|
||
f"{section_no}.", self.ds, size=self.tp('section_kicker', 14),
|
||
bold=True, color=self.ds.primary)
|
||
title_x = Inches(0.65)
|
||
else:
|
||
title_x = Inches(0.82)
|
||
# 标题宽度:到机密标记左侧为止(动态适配页面宽度)
|
||
title_w = Emu(int(self.ds.page_w - Inches(1.35) - title_x))
|
||
add_text(s, title_x, header_top, title_w, header_h,
|
||
title, self.ds, size=self.tp('page_title', 22), bold=True)
|
||
if subtitle:
|
||
sub_w = Emu(int(self.ds.page_w - Inches(1.35) - title_x))
|
||
add_text(s, title_x, sub_top, sub_w, Inches(0.35),
|
||
subtitle, self.ds, size=self.tp('page_subtitle', 12),
|
||
color=self.ds.text_dim)
|
||
add_rect(s, self.margin, div_y, self.content_w, Pt(1.2),
|
||
self.ds, fill=self.ds.line)
|
||
# 机密标记(右上角,红色标签)
|
||
if confidential:
|
||
# 根据页面宽度动态定位(右上角)
|
||
cw = Inches(0.9)
|
||
cx = Emu(int(self.ds.page_w - Inches(1.0)))
|
||
label = add_rect(s, cx, header_top - Inches(0.04), cw, Inches(0.34),
|
||
self.ds, fill=self.ds.danger)
|
||
tf = label.text_frame
|
||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
p0 = tf.paragraphs[0]
|
||
p0.alignment = PP_ALIGN.CENTER
|
||
r = p0.add_run()
|
||
r.text = confidential
|
||
set_run_font(r, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||
self.ds.font_for(confidential))
|
||
|
||
def _header_jp(self, s, title, subtitle, section_no, confidential):
|
||
"""report-jp 模板风格页眉:
|
||
章节行 y0.32 (20pt 含编号) → 主题行 y0.88 (16pt) → 装饰线 y1.52
|
||
"""
|
||
# 章节行(编号+标题 单行,20pt)
|
||
if section_no:
|
||
head_text = f"{section_no}. {title}"
|
||
else:
|
||
head_text = title
|
||
add_text(s, Inches(0.30), Inches(0.32), Inches(10.0), Inches(0.45),
|
||
head_text, self.ds, size=20, bold=False,
|
||
color=self.ds.text)
|
||
# 主题/副标题行(y0.88,16pt,模板为页面说明)
|
||
if subtitle:
|
||
add_text(s, Inches(0.38), Inches(0.88), Inches(10.0), Inches(0.6),
|
||
subtitle, self.ds, size=16, bold=False,
|
||
color=self.ds.text)
|
||
# 精致装饰线(页眉与内容区视觉分隔,主色细线+渐变尾)
|
||
line_y = Inches(1.52)
|
||
add_rect(s, self.margin, line_y, self.content_w, Pt(1.2),
|
||
self.ds, fill=self.ds.line)
|
||
# 装饰线左端主色块(视觉锚点)
|
||
add_rect(s, self.margin, line_y - Inches(0.015),
|
||
Inches(1.6), Pt(2.8), self.ds, fill=self.ds.primary)
|
||
# 机密标记(右上角,与模板位置一致 y0.42)
|
||
if confidential:
|
||
cw = Inches(0.9)
|
||
cx = Emu(int(self.ds.page_w - Inches(1.0)))
|
||
label = add_rect(s, cx, Inches(0.42), cw, Inches(0.34),
|
||
self.ds, fill=self.ds.danger)
|
||
tf = label.text_frame
|
||
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
p0 = tf.paragraphs[0]
|
||
p0.alignment = PP_ALIGN.CENTER
|
||
r = p0.add_run()
|
||
r.text = confidential
|
||
set_run_font(r, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||
self.ds.font_for(confidential))
|
||
|
||
def footer(self, s, idx, total, company="", date_text="", skip=False):
|
||
"""页脚:公司名(左)+ 页码(右),商务报告惯例。skip 用于封面/结束页"""
|
||
if skip:
|
||
return
|
||
lay = self.ds.layout
|
||
fy = Inches(lay["footer_top"])
|
||
foot_x = self.margin
|
||
if company:
|
||
add_text(s, foot_x, fy, Inches(4.0), Inches(0.3),
|
||
company, self.ds, size=9, color=self.ds.text_dim)
|
||
foot_x = Emu(int(foot_x + Inches(4.2)))
|
||
if date_text:
|
||
add_text(s, foot_x, fy, Inches(3.5), Inches(0.3),
|
||
date_text, self.ds, size=9, color=self.ds.text_dim)
|
||
if idx:
|
||
# 页码:与模板对齐(右下角)
|
||
px = Emu(int(self.ds.page_w - Inches(lay["page_num_x"]) - Inches(0.6)))
|
||
py = Inches(lay["page_num_y"])
|
||
add_text(s, px, py, Inches(0.6), Inches(0.35),
|
||
f"{idx:02d}", self.ds, size=11,
|
||
color=self.ds.text_dim, align=PP_ALIGN.RIGHT)
|
||
|
||
def render(self, slide_plan, idx, total, company="", date_text="",
|
||
notes_template=False):
|
||
stype = slide_plan.get("type", "content")
|
||
fn = getattr(self, f"r_{stype}", self.r_content)
|
||
fn(slide_plan, idx, total)
|
||
# 页脚(封面/结束页不显示页码)——页码定位随页面宽度
|
||
skip_footer = stype in ("cover", "end")
|
||
self.footer(self.prs.slides[-1], idx, total, company, date_text,
|
||
skip=skip_footer)
|
||
# 将 notes 写入真正的演讲者备注区(不显示在页面上)
|
||
notes_text = slide_plan.get("notes")
|
||
if notes_template and not notes_text:
|
||
notes_text = self._auto_notes(slide_plan, idx, total)
|
||
if notes_text:
|
||
try:
|
||
notes_slide = self.prs.slides[-1].notes_slide
|
||
tf = notes_slide.notes_text_frame
|
||
tf.text = str(notes_text)
|
||
except Exception:
|
||
pass
|
||
|
||
def _auto_notes(self, p, idx, total):
|
||
"""自动生成结构化演讲者备注(当 plan 未提供 notes 时)
|
||
模板:要点 / 数据提醒 / 过渡提示"""
|
||
lines = []
|
||
t = p.get("type", "content")
|
||
title = p.get("title", "")
|
||
if title:
|
||
lines.append(f"【本页主题】{title}")
|
||
# 数据提醒:提取 content 中的数字(排除目录序号/短编号)
|
||
nums = []
|
||
for key in ("content", "data"):
|
||
v = p.get(key)
|
||
if isinstance(v, list):
|
||
for item in v:
|
||
if isinstance(item, str):
|
||
for m in re.findall(r'\d+(?:[.,]\d+)?\s*[%%]?', item):
|
||
m = m.strip()
|
||
# 排除目录编号 "01." 类
|
||
if re.fullmatch(r'\d{1,2}', m) and not re.search(r'[%%]', m):
|
||
continue
|
||
if m and m not in nums:
|
||
nums.append(m)
|
||
if nums:
|
||
lines.append("【数据提醒】本页包含关键数字:" + "、".join(nums[:8]))
|
||
# 内容要点(去掉标记)
|
||
points = []
|
||
for key in ("content",):
|
||
v = p.get(key)
|
||
if isinstance(v, list):
|
||
for item in v:
|
||
if isinstance(item, str) and item.strip():
|
||
pts = item.strip()
|
||
pts = pts.replace('**', '') # 先移除加粗标记
|
||
pts = re.sub(r'^[-*]\s*', '', pts) # 再去行首符号
|
||
if len(pts) > 3:
|
||
points.append(pts)
|
||
if points:
|
||
lines.append("【核心要点】")
|
||
for pt in points[:3]:
|
||
lines.append(f" · {pt[:50]}")
|
||
# 过渡提示
|
||
lines.append("【过渡提示】总结本页要点后自然过渡到下一页。")
|
||
return "\n".join(lines)
|
||
|
||
# ---- cover(支持 stats 大数字统计 + 装饰图形)
|
||
def r_cover(self, p, idx, total):
|
||
s = self.new()
|
||
pw = self.ds.page_w
|
||
# 底部渐变条
|
||
add_rect(s, Inches(0), Inches(6.9), pw, Inches(0.6),
|
||
self.ds, fill=self.ds.panel2)
|
||
# 右上角装饰圆环(视觉焦点)
|
||
add_shape(s, MSO_SHAPE.OVAL,
|
||
Emu(int(pw - Inches(2.2))), Inches(-0.8),
|
||
Inches(3.0), Inches(3.0), self.ds,
|
||
fill=None, line=self.ds.primary)
|
||
add_text(s, self.margin, Inches(0.8), Inches(5.0), Inches(0.4),
|
||
p.get("kicker", ""), self.ds,
|
||
size=self.tp('section_kicker', 14),
|
||
color=self.ds.primary)
|
||
# 标题宽度自适应页面
|
||
title_w = Emu(int(pw - 2 * self.margin))
|
||
# 有 stats 时标题区压缩(数字墙占右侧)
|
||
has_stats = p.get("stats")
|
||
if has_stats:
|
||
title_w = Emu(int(pw - self.margin - Inches(3.6)))
|
||
# 标题字号按长度和可用宽度自适应(长标题/窄页面自动缩小)
|
||
cover_title = p.get("title", "演示文稿")
|
||
base_sz = self.tp('cover_title', 38)
|
||
title_chars = len(cover_title)
|
||
if title_chars > 20:
|
||
cover_sz = min(base_sz, 26)
|
||
elif title_chars > 14:
|
||
cover_sz = min(base_sz, 30)
|
||
else:
|
||
cover_sz = base_sz
|
||
# stats 存在时标题区更窄,再缩一档
|
||
if has_stats and cover_sz > 24:
|
||
cover_sz = min(cover_sz, 24)
|
||
add_text(s, self.margin, Inches(2.2), title_w, Inches(1.4),
|
||
cover_title, self.ds,
|
||
size=cover_sz, bold=True, line_spacing=1.2)
|
||
if p.get("subtitle"):
|
||
add_text(s, self.margin, Inches(3.6), title_w, Inches(0.6),
|
||
p["subtitle"], self.ds,
|
||
size=self.tp('cover_subtitle', 18),
|
||
color=self.ds.text_dim)
|
||
if p.get("content"):
|
||
add_text(s, self.margin, Inches(4.4),
|
||
Emu(int(title_w - Inches(0.5))), Inches(1.6),
|
||
"\n".join(p["content"]), self.ds,
|
||
size=self.tp('cover_body', 15),
|
||
color=self.ds.text_dim, line_spacing=1.5)
|
||
add_rect(s, self.margin, Inches(5.7), Inches(2.8), Pt(2.5),
|
||
self.ds, fill=self.ds.primary)
|
||
# 大数字统计墙(右侧,封面视觉焦点)
|
||
if has_stats:
|
||
self._cover_stats(s, p["stats"], pw)
|
||
|
||
def _cover_stats(self, s, stats, pw):
|
||
"""封面大数字统计(右侧竖排卡片)"""
|
||
x = Emu(int(pw - Inches(3.3)))
|
||
y0 = Inches(1.9)
|
||
card_w = Inches(2.7)
|
||
card_h = Inches(1.15)
|
||
for i, (num, label) in enumerate(stats):
|
||
y = y0 + i * (card_h + Inches(0.22))
|
||
# 数字卡片
|
||
add_rect(s, x, y, card_w, card_h, self.ds,
|
||
fill=self.ds.panel, radius=True, shadow=True)
|
||
add_rect(s, x, y, card_w, Inches(0.06), self.ds,
|
||
fill=self.ds.chart_colors[i % 6])
|
||
add_text(s, x, y + Inches(0.08), card_w, Inches(0.62),
|
||
num, self.ds, size=26, bold=True,
|
||
color=self.ds.chart_colors[i % 6],
|
||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||
add_text(s, x + Inches(0.1), y + Inches(0.74),
|
||
card_w - Inches(0.2), Inches(0.35),
|
||
label, self.ds, size=11,
|
||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||
|
||
# ---- toc
|
||
def r_toc(self, p, idx, total):
|
||
s = self.new()
|
||
self.header(s, p.get("title", "目录"),
|
||
p.get("subtitle"), idx, total,
|
||
section_no=p.get("section_no"),
|
||
confidential=p.get("confidential"))
|
||
items = p.get("content", [])
|
||
x0, y0 = self.margin, Inches(self.ds.layout["body_top"])
|
||
# 卡片宽度自适应:两列 + 间距
|
||
gap = Inches(0.3)
|
||
cw = Emu(int((self.content_w - gap) / 2))
|
||
ch = Inches(1.35)
|
||
for i, item in enumerate(items):
|
||
col, row = i % 2, i // 2
|
||
x = x0 + col * (cw + gap)
|
||
y = y0 + row * (ch + Inches(0.28))
|
||
card = add_rect(s, x, y, cw, ch, self.ds, fill=self.ds.panel,
|
||
radius=True, shadow=True)
|
||
# 左侧色条(彩色,随条目序号变化)
|
||
add_rect(s, x, y, Inches(0.07), ch, self.ds,
|
||
fill=self.ds.chart_colors[i % 6])
|
||
num = item.split('.')[0] if '.' in item else f"{i+1:02d}"
|
||
title = item.split('.', 1)[1].strip() if '.' in item else item
|
||
# 序号:彩色圆形徽章
|
||
badge_r = Inches(0.34)
|
||
badge = add_shape(s, MSO_SHAPE.OVAL,
|
||
x + Inches(0.3), y + Inches(0.34),
|
||
badge_r * 2, badge_r * 2, self.ds,
|
||
fill=self.ds.chart_colors[i % 6])
|
||
btf = badge.text_frame
|
||
btf.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
bp = btf.paragraphs[0]
|
||
bp.alignment = PP_ALIGN.CENTER
|
||
br = bp.add_run()
|
||
br.text = num
|
||
set_run_font(br, 16, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||
self.ds.font_cn)
|
||
# 条目标题(徽章右侧)
|
||
add_text(s, x + Inches(1.05), y + Inches(0.4),
|
||
cw - Inches(1.3), Inches(0.6),
|
||
title, self.ds,
|
||
size=self.tp('body', 14), bold=True)
|
||
|
||
# ---- section
|
||
def r_section(self, p, idx, total):
|
||
s = self.new()
|
||
pw = self.ds.page_w
|
||
title_w = Emu(int(pw - 2 * self.margin))
|
||
# 左侧装饰竖条
|
||
add_rect(s, self.margin, Inches(2.4), Inches(0.1), Inches(1.6),
|
||
self.ds, fill=self.ds.primary)
|
||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(2.6),
|
||
Inches(5.0), Inches(0.5),
|
||
p.get("kicker", "SECTION"), self.ds,
|
||
size=self.tp('section_kicker', 14),
|
||
color=self.ds.primary)
|
||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(3.1),
|
||
title_w, Inches(1.2),
|
||
p.get("title", ""), self.ds,
|
||
size=self.tp('section_title', 34), bold=True)
|
||
if p.get("content"):
|
||
add_text(s, Emu(int(self.margin + Inches(0.4))), Inches(4.5),
|
||
Emu(int(title_w - Inches(0.5))), Inches(1.2),
|
||
"\n".join(p["content"]), self.ds,
|
||
size=self.tp('body', 16), color=self.ds.text_dim)
|
||
# 底部主色横线(视觉收尾)
|
||
add_rect(s, self.margin, Inches(5.9), Inches(2.8), Pt(2.5),
|
||
self.ds, fill=self.ds.primary)
|
||
|
||
# ---- content(智能布局:自动识别要点卡片 / 普通文本)
|
||
def r_content(self, p, idx, total):
|
||
s = self.new()
|
||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||
section_no=p.get("section_no"),
|
||
confidential=p.get("confidential"))
|
||
content = p.get("content", [])
|
||
y0 = Inches(self.ds.layout["body_top"])
|
||
body_h = Inches(self.ds.layout["body_bottom"] - self.ds.layout["body_top"])
|
||
|
||
# 右侧配图(plan 页面级 image 字段,文件存在才启用左文右图布局)
|
||
img_path = p.get("image")
|
||
has_img = bool(img_path) and Path(str(img_path)).exists()
|
||
avail_w = Emu(int(self.content_w * 0.58)) if has_img else self.content_w
|
||
|
||
# 智能检测:是否有 **标题** 开头的要点(渲染为卡片网格)
|
||
items = []
|
||
plain_lines = []
|
||
for line in content:
|
||
stripped = line.strip()
|
||
if stripped.startswith("**") and "**" in stripped[2:]:
|
||
# 提取 **标题** 和后续内容
|
||
end = stripped.find("**", 2)
|
||
title = stripped[2:end]
|
||
rest = stripped[end+2:].strip()
|
||
items.append((title, rest))
|
||
elif stripped:
|
||
plain_lines.append(stripped)
|
||
|
||
if items and not plain_lines:
|
||
# 卡片网格布局(2列,每卡片带色条+标题+描述)
|
||
self._content_cards(s, items, y0, body_h, w=avail_w)
|
||
elif items and plain_lines:
|
||
# 混合:要点卡片 + 底部说明文字
|
||
self._content_cards(s, items, y0, Emu(int(body_h * 0.8)), w=avail_w)
|
||
if plain_lines:
|
||
add_text(s, self.margin,
|
||
Emu(int(y0 + body_h * 0.85)),
|
||
avail_w, Emu(int(body_h * 0.15)),
|
||
"\n".join(plain_lines), self.ds,
|
||
size=self.tp('body_small', 12),
|
||
color=self.ds.text_dim, line_spacing=1.3)
|
||
else:
|
||
# 普通文本布局
|
||
add_text(s, self.margin, y0, avail_w, body_h,
|
||
"\n".join(content), self.ds, size=self.tp('body', 15),
|
||
line_spacing=1.4, space_after=8)
|
||
|
||
if has_img:
|
||
self._place_image(s, str(img_path), y0, body_h)
|
||
|
||
def _place_image(self, s, img_path, y0, body_h):
|
||
"""在内容区右侧放置配图:宽占 42%,等比缩放、垂直居中,带细边框。
|
||
图片缺失/损坏时静默跳过(返回 False),不影响整页渲染。"""
|
||
zone_w = Emu(int(self.content_w * 0.40))
|
||
zx = Emu(int(self.margin + self.content_w - zone_w))
|
||
max_h = Emu(int(body_h - Inches(0.12)))
|
||
try:
|
||
pic = s.shapes.add_picture(img_path, zx, y0, width=zone_w)
|
||
except Exception:
|
||
return False
|
||
if pic.height > max_h:
|
||
ratio = max_h / pic.height
|
||
new_w = Emu(int(pic.width * ratio))
|
||
pic.height = max_h
|
||
pic.width = new_w
|
||
pic.left = Emu(int(zx + int((zone_w - new_w) / 2)))
|
||
if pic.height < body_h:
|
||
pic.top = Emu(int(y0 + int((body_h - pic.height) / 2)))
|
||
try:
|
||
pic.line.color.rgb = self.ds.line
|
||
pic.line.width = Pt(1)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
def _content_cards(self, s, items, y0, body_h, w=None):
|
||
"""要点卡片网格:2列 × N行,每卡片带左侧色条+标题+描述"""
|
||
n = len(items)
|
||
if not n:
|
||
return
|
||
grid_w = w if w is not None else self.content_w
|
||
gap = Inches(0.3)
|
||
col_w = Emu(int((grid_w - gap) / 2))
|
||
# 卡片高度自适应(最多3行卡片)
|
||
rows = (n + 1) // 2
|
||
card_h = Emu(int((body_h - gap * (rows - 1)) / rows))
|
||
# 卡片多时压缩字号(避免描述溢出)
|
||
desc_sz = self.tp('body_small', 12)
|
||
title_sz = self.tp('card_title', 16)
|
||
desc_ls = 1.35
|
||
if rows >= 3 or card_h < Inches(1.5):
|
||
desc_sz = max(10, desc_sz - 1)
|
||
title_sz = max(13, title_sz - 1)
|
||
desc_ls = 1.25
|
||
for i, (title, desc) in enumerate(items):
|
||
col, row = i % 2, i // 2
|
||
x = self.margin + col * (col_w + gap)
|
||
y = y0 + row * (card_h + gap)
|
||
# 卡片背景(带柔和投影)
|
||
add_rect(s, x, y, col_w, card_h, self.ds,
|
||
fill=self.ds.panel, radius=True, shadow=True)
|
||
# 左侧色条
|
||
add_rect(s, x, y, Inches(0.07), card_h, self.ds,
|
||
fill=self.ds.chart_colors[i % 6])
|
||
# 标题
|
||
add_text(s, x + Inches(0.28), y + Inches(0.12),
|
||
col_w - Inches(0.5), Inches(0.38),
|
||
title, self.ds, size=title_sz,
|
||
bold=True, color=self.ds.primary, line_spacing=1.15)
|
||
# 描述
|
||
if desc:
|
||
add_text(s, x + Inches(0.28), y + Inches(0.56),
|
||
col_w - Inches(0.5), card_h - Inches(0.72),
|
||
desc, self.ds, size=desc_sz,
|
||
color=self.ds.text, line_spacing=desc_ls)
|
||
|
||
# ---- two_column
|
||
def r_two_column(self, p, idx, total):
|
||
s = self.new()
|
||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||
section_no=p.get("section_no"),
|
||
confidential=p.get("confidential"))
|
||
cols = p.get("content", [])
|
||
gap = Inches(0.35)
|
||
cw = Emu(int((self.content_w - gap) / 2))
|
||
body_top = Inches(self.ds.layout["body_top"])
|
||
body_bottom = Inches(self.ds.layout["body_bottom"])
|
||
card_h = Emu(int(body_bottom - body_top))
|
||
# 窄页面/长内容时字号自动适配:卡片可用高度不足则缩小
|
||
card_font = 13
|
||
if cw < Inches(5.0):
|
||
card_font = 12
|
||
for ci in range(2):
|
||
x = self.margin + ci * (cw + gap)
|
||
y = body_top
|
||
card = add_rect(s, x, y, cw, card_h, self.ds,
|
||
fill=self.ds.panel, radius=True, shadow=True)
|
||
head = p.get(f"col{ci+1}_title", f"栏目 {ci+1}")
|
||
add_text(s, x + Inches(0.3), y + Inches(0.25), cw - Inches(0.6),
|
||
Inches(0.5), head, self.ds, size=self.tp('card_title', 16),
|
||
bold=True, color=self.ds.primary)
|
||
if ci < len(cols):
|
||
# 文本区自适应卡片剩余高度
|
||
text_top = y + Inches(0.9)
|
||
text_h = Emu(int(card_h - Inches(1.15)))
|
||
add_text(s, x + Inches(0.3), text_top,
|
||
cw - Inches(0.6), text_h,
|
||
"\n".join(cols[ci]), self.ds,
|
||
size=self.tp('body_small', 13),
|
||
line_spacing=1.3, space_after=6)
|
||
|
||
# ---- table(支持可选底部图表 chart)
|
||
def r_table(self, p, idx, total):
|
||
s = self.new()
|
||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||
section_no=p.get("section_no"),
|
||
confidential=p.get("confidential"))
|
||
data = p.get("data", [])
|
||
if not data:
|
||
return
|
||
rows, cols = len(data), len(data[0])
|
||
lay = self.ds.layout
|
||
x0, y0 = self.margin, Inches(lay["body_top"])
|
||
tw = self.content_w
|
||
body_avail = Emu(int(Inches(lay["body_bottom"]) - Inches(lay["body_top"])))
|
||
# 表格+图表混合时,表格占上部 58%,图表占下部 42%
|
||
has_chart = p.get("chart") or p.get("charts")
|
||
if has_chart:
|
||
table_area = Emu(int(body_avail * 0.58))
|
||
else:
|
||
table_area = body_avail
|
||
# 行高自适应:表格头 + (rows-1) 行 分配 table_area
|
||
th = Inches(0.5)
|
||
rh = Emu(int((table_area - th) / max(rows - 1, 1)))
|
||
# 行高下限保护:按 12pt 文字+边距计算最小行高(约0.42in)
|
||
# 空间不足时优先压缩表头,保证数据行有足够高度
|
||
min_rh = Inches(0.42)
|
||
if rh < min_rh:
|
||
rh = min_rh
|
||
# 表头高度 = 剩余空间(若负则回到可接受最小值)
|
||
th_remain = Emu(int(table_area - rh * (rows - 1)))
|
||
th = th_remain if th_remain > Inches(0.35) else Inches(0.35)
|
||
cw = Emu(int(tw / cols))
|
||
# header
|
||
for c in range(cols):
|
||
add_rect(s, x0 + c * cw, y0, cw, th, self.ds,
|
||
fill=self.ds.panel2)
|
||
add_text(s, x0 + c * cw + Inches(0.1), y0 + Inches(0.12),
|
||
cw - Inches(0.2), Inches(0.35), str(data[0][c]),
|
||
self.ds, size=self.tp('table_head', 13), bold=True,
|
||
color=self.ds.primary,
|
||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||
for r in range(1, rows):
|
||
cy = y0 + th + (r - 1) * rh
|
||
fill = self.ds.bg2 if r % 2 == 0 else self.ds.panel
|
||
for c in range(cols):
|
||
add_rect(s, x0 + c * cw, cy, cw, rh - Inches(0.04),
|
||
self.ds, fill=fill)
|
||
add_text(s, x0 + c * cw + Inches(0.1), cy + Inches(0.1),
|
||
cw - Inches(0.2), rh - Inches(0.2),
|
||
str(data[r][c]), self.ds,
|
||
size=self.tp('table_cell', 12),
|
||
color=self.ds.text, align=PP_ALIGN.CENTER,
|
||
anchor=MSO_ANCHOR.MIDDLE)
|
||
# 底部图表(单个或多个并排)
|
||
if has_chart:
|
||
table_actual_h = Emu(int(th + rh * (rows - 1)))
|
||
chart_top = Emu(int(y0 + table_actual_h + Inches(0.2)))
|
||
chart_bottom = Emu(int(Inches(lay["body_bottom"])))
|
||
if p.get("charts"):
|
||
self._draw_charts(s, p.get("charts"), chart_top,
|
||
chart_bottom - chart_top)
|
||
elif p.get("chart"):
|
||
self._draw_charts(s, [p.get("chart")], chart_top,
|
||
chart_bottom - chart_top)
|
||
|
||
# ---- two_table(左右双表并排)
|
||
def r_two_table(self, p, idx, total):
|
||
"""左右两个表格并排。plan 结构:
|
||
tables: [{title, data, col_ratio?}, ...] 或 table1/table2
|
||
"""
|
||
s = self.new()
|
||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||
section_no=p.get("section_no"),
|
||
confidential=p.get("confidential"))
|
||
tables = p.get("tables", [])
|
||
if not tables:
|
||
return
|
||
lay = self.ds.layout
|
||
y0 = Inches(lay["body_top"])
|
||
body_bottom = Inches(lay["body_bottom"])
|
||
gap = Inches(0.3)
|
||
n = len(tables)
|
||
# 表格宽度:等分或按 col_ratio
|
||
ratios = [t.get("col_ratio", 1) for t in tables]
|
||
total_r = sum(ratios)
|
||
gap_total = gap * (n - 1)
|
||
avail_w = self.content_w - Emu(int(gap_total))
|
||
widths = [Emu(int(avail_w * r / total_r)) for r in ratios]
|
||
for ti, t in enumerate(tables):
|
||
tdata = t.get("data", [])
|
||
if not tdata:
|
||
continue
|
||
rows, cols = len(tdata), len(tdata[0])
|
||
tx = self.margin + sum(widths[:ti]) + ti * gap
|
||
tw = widths[ti]
|
||
# 子标题(表格上方)
|
||
sub_title = t.get("title", "")
|
||
if sub_title:
|
||
add_text(s, tx, y0, tw, Inches(0.3),
|
||
sub_title, self.ds, size=11, bold=True,
|
||
color=self.ds.primary, align=PP_ALIGN.CENTER)
|
||
# 表头高度:长文本(日文/多字符)加高,支持换行
|
||
head_sz = 10
|
||
if tw < Inches(4.5) or cols >= 5:
|
||
head_sz = 9
|
||
th = Inches(0.42)
|
||
# 检查表头是否有长文本需要更高
|
||
head_max_len = max(len(str(tdata[0][c])) for c in range(cols))
|
||
if head_max_len > 10:
|
||
th = Inches(0.55)
|
||
# 行高:按表格区域分配(双表用全部 body 高度)
|
||
table_area = Emu(int(body_bottom - y0 - Inches(0.42)))
|
||
rh = Emu(int((table_area - th) / max(rows - 1, 1)))
|
||
cell_sz = 10
|
||
if tw < Inches(4.5) or cols >= 5:
|
||
cell_sz = 9
|
||
min_rh = Inches(cell_sz / 72 * 1.6 + 0.12)
|
||
if rh < min_rh:
|
||
rh = min_rh
|
||
cw = Emu(int(tw / cols))
|
||
# 表头
|
||
for c in range(cols):
|
||
add_rect(s, tx + c * cw, y0, cw, th, self.ds,
|
||
fill=self.ds.panel2)
|
||
add_text(s, tx + c * cw + Inches(0.04), y0 + Inches(0.05),
|
||
cw - Inches(0.08), th - Inches(0.08),
|
||
str(tdata[0][c]), self.ds, size=head_sz,
|
||
bold=True, color=self.ds.primary,
|
||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
||
line_spacing=1.0)
|
||
# 数据行
|
||
for r in range(1, rows):
|
||
cy = y0 + th + (r - 1) * rh
|
||
fill = self.ds.bg2 if r % 2 == 0 else self.ds.panel
|
||
for c in range(cols):
|
||
add_rect(s, tx + c * cw, cy, cw, rh - Inches(0.02),
|
||
self.ds, fill=fill)
|
||
add_text(s, tx + c * cw + Inches(0.04), cy + Inches(0.04),
|
||
cw - Inches(0.08), rh - Inches(0.08),
|
||
str(tdata[r][c]), self.ds, size=cell_sz,
|
||
color=self.ds.text, align=PP_ALIGN.CENTER,
|
||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.0)
|
||
|
||
# ---- chart(支持单图 chart 或多图 charts 数组)
|
||
def r_chart(self, p, idx, total):
|
||
s = self.new()
|
||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||
section_no=p.get("section_no"),
|
||
confidential=p.get("confidential"))
|
||
lay = self.ds.layout
|
||
cx, cy = self.margin, Inches(lay["body_top"])
|
||
cw = self.content_w
|
||
ch = Emu(int(Inches(lay["body_bottom"]) - Inches(lay["body_top"])))
|
||
if p.get("charts"):
|
||
self._draw_charts(s, p.get("charts"), cy, ch)
|
||
elif p.get("chart"):
|
||
self._draw_charts(s, [p.get("chart")], cy, ch)
|
||
|
||
def _draw_charts(self, s, charts, cy, ch):
|
||
"""绘制一个或多个图表(并排布局)"""
|
||
n = len(charts)
|
||
if not n:
|
||
return
|
||
gap = Inches(0.3)
|
||
cw = Emu(int((self.content_w - gap * (n - 1)) / n))
|
||
cx = self.margin
|
||
for i, chart in enumerate(charts):
|
||
x = cx + i * (cw + gap)
|
||
self._draw_chart_single(s, chart, x, cy, cw, ch)
|
||
|
||
def _draw_chart_single(self, s, chart, cx, cy, cw, ch):
|
||
"""绘制单个图表(带标题),供单图/多图/表格混合调用"""
|
||
ctype = chart.get("type", "bar")
|
||
data = self._resolve_chart_data(chart)
|
||
if not data:
|
||
return
|
||
# 面板背景
|
||
panel = add_rect(s, cx, cy, cw, ch, self.ds, fill=self.ds.panel,
|
||
radius=True, shadow=True)
|
||
# 子标题(图表标题,顶部居中)
|
||
sub_title = chart.get("title", "")
|
||
if sub_title:
|
||
add_text(s, cx + Inches(0.2), cy + Inches(0.12),
|
||
cw - Inches(0.4), Inches(0.35),
|
||
sub_title, self.ds, size=self.tp('card_title', 14),
|
||
bold=True, color=self.ds.primary,
|
||
align=PP_ALIGN.CENTER)
|
||
# 绘图区(面板内,标题下方)
|
||
inner_cy = Emu(int(cy + Inches(0.5)))
|
||
inner_ch = Emu(int(ch - Inches(0.55)))
|
||
# 图例:仅饼图/多系列时显示
|
||
show_legend = ctype == "pie" or len(data) > 6
|
||
if show_legend:
|
||
legend_y = Emu(int(inner_cy + Inches(0.05)))
|
||
for i, (k, _v) in enumerate(data.items()):
|
||
lx = cx + Inches(0.3) + i * Inches(1.8)
|
||
add_shape(s, MSO_SHAPE.RECTANGLE, lx, legend_y + Inches(0.05),
|
||
Inches(0.2), Inches(0.2), self.ds,
|
||
fill=self.ds.chart_colors[i % 6])
|
||
add_text(s, lx + Inches(0.28), legend_y, Inches(1.4),
|
||
Inches(0.3), k, self.ds,
|
||
size=self.tp('table_cell', 10),
|
||
color=self.ds.text_dim)
|
||
inner_cy = Emu(int(inner_cy + Inches(0.35)))
|
||
inner_ch = Emu(int(inner_ch - Inches(0.35)))
|
||
if ctype == "pie":
|
||
self._draw_pie(s, cx, inner_cy, cw, inner_ch, data)
|
||
elif ctype == "line":
|
||
self._draw_line(s, cx, inner_cy, cw, inner_ch, data)
|
||
else: # bar
|
||
self._draw_bar(s, cx, inner_cy, cw, inner_ch, data)
|
||
|
||
def _resolve_chart_data(self, chart):
|
||
src = chart.get("source")
|
||
if src and os.path.exists(src):
|
||
ext = os.path.splitext(src)[1].lower()
|
||
if ext == ".csv":
|
||
with open(src, "r", encoding="utf-8-sig") as f:
|
||
reader = csv.reader(f)
|
||
rows = list(reader)
|
||
if rows:
|
||
labels = [r[0] for r in rows[1:]] or [r[0] for r in rows]
|
||
values = [float(r[1]) for r in rows[1:]]
|
||
return dict(zip(labels, values))
|
||
return chart.get("data", {})
|
||
|
||
def _draw_bar(self, s, cx, cy, cw, ch, data):
|
||
items = list(data.items())
|
||
if not items:
|
||
return
|
||
# 绘图区布局:标题/图例下方留出类别标签空间
|
||
label_h = Inches(0.55) # 类别标签区(支持2行)
|
||
plot_x = cx + Inches(0.7)
|
||
plot_y = cy + Inches(0.7)
|
||
plot_w = cw - Inches(1.4)
|
||
plot_h = ch - Inches(0.7) - label_h - Inches(0.15)
|
||
max_v = max(v for _, v in items) or 1
|
||
n = len(items)
|
||
# 柱宽:按数据量自适应
|
||
bar_gap = Inches(0.22) if n <= 6 else Inches(0.14)
|
||
bar_w = Emu(int((plot_w - bar_gap * (n - 1)) / n))
|
||
# 网格线(3条水平辅助线,增强可读性)
|
||
for gi in range(1, 4):
|
||
gy = plot_y + Emu(int(plot_h * gi / 4))
|
||
add_rect(s, plot_x, gy, plot_w, Pt(0.75), self.ds,
|
||
fill=self.ds.line)
|
||
# 基线
|
||
base_y = plot_y + plot_h
|
||
add_rect(s, plot_x, base_y, plot_w, Pt(2), self.ds,
|
||
fill=self.ds.line)
|
||
max_i = max(range(n), key=lambda i: items[i][1]) if n else 0
|
||
for i, (k, v) in enumerate(items):
|
||
hgt = Emu(int(plot_h * (v / max_v)))
|
||
# 最小柱高(避免0值柱不可见)
|
||
if hgt < Inches(0.08):
|
||
hgt = Inches(0.08)
|
||
bx = plot_x + i * (bar_w + bar_gap)
|
||
by = base_y - hgt
|
||
base_color = self.ds.chart_colors[i % 6]
|
||
# 渐变柱体:顶部提亮40% → 底部原色(立体感)
|
||
lighter = RGBColor(
|
||
min(255, int(base_color[0] + (255 - base_color[0]) * 0.45)),
|
||
min(255, int(base_color[1] + (255 - base_color[1]) * 0.45)),
|
||
min(255, int(base_color[2] + (255 - base_color[2]) * 0.45)))
|
||
add_gradient_rect(s, bx, by, bar_w, hgt, self.ds,
|
||
lighter, base_color, radius=True)
|
||
# 数值标签(柱顶上方)
|
||
add_text(s, bx, by - Inches(0.36), bar_w, Inches(0.3),
|
||
str(v), self.ds,
|
||
size=self.tp('chart_label', 12), bold=True,
|
||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||
# 类别标签(柱下方,支持2行)
|
||
add_text(s, bx - Inches(0.05),
|
||
base_y + Inches(0.08),
|
||
bar_w + Inches(0.1), label_h,
|
||
k, self.ds, size=self.tp('chart_cat', 9),
|
||
color=self.ds.text_dim, align=PP_ALIGN.CENTER,
|
||
anchor=MSO_ANCHOR.TOP, line_spacing=1.15)
|
||
|
||
def _draw_line(self, s, cx, cy, cw, ch, data):
|
||
items = list(data.items())
|
||
if len(items) < 2:
|
||
self._draw_bar(s, cx, cy, cw, ch, data)
|
||
return
|
||
plot_x = cx + Inches(0.9)
|
||
plot_y = cy + Inches(1.0)
|
||
plot_w = cw - Inches(1.6)
|
||
plot_h = ch - Inches(1.6)
|
||
max_v = max(v for _, v in items) or 1
|
||
min_v = min(v for _, v in items) or 0
|
||
span = (max_v - min_v) or 1
|
||
pts = []
|
||
step = Emu(int(plot_w / (len(items) - 1)))
|
||
for i, (k, v) in enumerate(items):
|
||
px = plot_x + i * step
|
||
py = plot_y + plot_h - Emu(int(plot_h * ((v - min_v) / span)))
|
||
pts.append((px, py))
|
||
# 连线
|
||
for i in range(len(pts) - 1):
|
||
x1, y1 = pts[i]
|
||
x2, y2 = pts[i + 1]
|
||
mid_y = Emu(int((y1 + y2) / 2))
|
||
ln = add_shape(s, MSO_SHAPE.RECTANGLE, x1, y1,
|
||
Emu(max(int(x2 - x1), 1000)), Pt(3), self.ds,
|
||
fill=self.ds.primary)
|
||
# 点与标签
|
||
for i, ((px, py), (k, v)) in enumerate(zip(pts, items)):
|
||
add_shape(s, MSO_SHAPE.OVAL, px - Inches(0.07),
|
||
py - Inches(0.07), Inches(0.14), Inches(0.14),
|
||
self.ds, fill=self.ds.chart_colors[i % 6])
|
||
add_text(s, px - Inches(0.5), py - Inches(0.4), Inches(1.0),
|
||
Inches(0.3), str(v), self.ds, size=10, bold=True,
|
||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||
add_text(s, px - Inches(0.6), plot_y + plot_h + Inches(0.06),
|
||
Inches(1.2), Inches(0.3), k, self.ds, size=9,
|
||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||
|
||
def _draw_pie(self, s, cx, cy, cw, ch, data):
|
||
items = list(data.items())
|
||
total = sum(v for _, v in items) or 1
|
||
r = Inches(1.6)
|
||
pcx = cx + Inches(3.0)
|
||
pcy = cy + Inches(2.6)
|
||
angle = -90.0
|
||
from pptx.enum.shapes import MSO_SHAPE as MS
|
||
for i, (k, v) in enumerate(items):
|
||
frac = v / total
|
||
sweep = frac * 360.0
|
||
shp = add_shape(s, MS.PIE, pcx - r, pcy - r, 2 * r, 2 * r,
|
||
self.ds, fill=self.ds.chart_colors[i % 6])
|
||
try:
|
||
shp.adjustments[0] = angle
|
||
shp.adjustments[1] = angle + sweep
|
||
except Exception:
|
||
pass
|
||
# 标签
|
||
mid = math.radians(angle + sweep / 2)
|
||
lx = pcx + Emu(int(r * 0.62 * math.cos(mid))) - Inches(0.5)
|
||
ly = pcy + Emu(int(r * 0.62 * math.sin(mid))) - Inches(0.15)
|
||
add_text(s, lx, ly, Inches(1.0), Inches(0.3),
|
||
f"{k} {v}", self.ds, size=10, bold=True,
|
||
color=self.ds.text, align=PP_ALIGN.CENTER)
|
||
angle += sweep
|
||
|
||
# ---- timeline
|
||
def r_timeline(self, p, idx, total):
|
||
s = self.new()
|
||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||
section_no=p.get("section_no"),
|
||
confidential=p.get("confidential"))
|
||
items = p.get("content", [])
|
||
n = len(items)
|
||
if not n:
|
||
return
|
||
x0 = self.margin
|
||
# 时间线主线位于 body 区域中部
|
||
body_top = self.ds.layout["body_top"]
|
||
body_bottom = self.ds.layout["body_bottom"]
|
||
y_line = Inches(body_top + (body_bottom - body_top) * 0.28)
|
||
line_w = self.content_w
|
||
# 渐变主线(深→浅)
|
||
add_rect(s, x0, y_line, line_w, Pt(3), self.ds, fill=self.ds.line)
|
||
cw = Emu(int(line_w / n))
|
||
for i, item in enumerate(items):
|
||
x = x0 + i * cw
|
||
cx = x + Inches(0.2)
|
||
# 双圈节点:外圈描边 + 内圈实心
|
||
add_shape(s, MSO_SHAPE.OVAL, cx - Inches(0.12), y_line - Inches(0.12),
|
||
Inches(0.24), Inches(0.24), self.ds,
|
||
fill=self.ds.bg, line=self.ds.primary)
|
||
add_shape(s, MSO_SHAPE.OVAL, cx - Inches(0.06), y_line - Inches(0.06),
|
||
Inches(0.12), Inches(0.12), self.ds,
|
||
fill=self.ds.primary)
|
||
# 序号(节点下方小徽章)
|
||
add_text(s, cx - Inches(0.3), y_line - Inches(0.5), Inches(0.6),
|
||
Inches(0.25), f"{i+1:02d}", self.ds,
|
||
size=self.tp('timeline_desc', 9), bold=True,
|
||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||
# 阶段
|
||
parts = item.split('\n')
|
||
stage = parts[0] if parts else ""
|
||
desc = '\n'.join(parts[1:]) if len(parts) > 1 else ""
|
||
add_text(s, x, y_line + Inches(0.25), cw - Inches(0.1),
|
||
Inches(0.4), stage, self.ds,
|
||
size=self.tp('timeline_title', 13), bold=True,
|
||
color=self.ds.primary, align=PP_ALIGN.LEFT)
|
||
if desc:
|
||
add_text(s, x, y_line + Inches(0.65), cw - Inches(0.15),
|
||
Inches(1.5), desc, self.ds,
|
||
size=self.tp('timeline_desc', 10),
|
||
color=self.ds.text_dim, line_spacing=1.3)
|
||
|
||
# ---- flowchart(流程/泳道图,参考日企报告会资料)
|
||
def r_flowchart(self, p, idx, total):
|
||
s = self.new()
|
||
self.header(s, p.get("title", ""), p.get("subtitle"), idx, total,
|
||
section_no=p.get("section_no"),
|
||
confidential=p.get("confidential"))
|
||
rows = p.get("rows", []) # 泳道标题列表
|
||
steps = p.get("content", []) # 步骤列表 ["阶段A", "阶段B", ...]
|
||
x0, y0 = self.margin, Inches(self.ds.layout["body_top"])
|
||
cw = self.content_w
|
||
# 可用高度(到 body_bottom)
|
||
avail_h = self.ds.layout["body_bottom"] - self.ds.layout["body_top"]
|
||
if rows:
|
||
# 泳道模式:rows 为行标题,content 为每行步骤列表
|
||
rh = Emu(int((Inches(avail_h) - Inches(0.3) * (len(rows) - 1)) / len(rows)))
|
||
for ri, row_title in enumerate(rows):
|
||
ry = y0 + ri * (rh + Inches(0.3))
|
||
# 行标题
|
||
add_rect(s, x0, ry, Inches(1.5), rh, self.ds,
|
||
fill=self.ds.primary, radius=True)
|
||
add_text(s, x0, ry, Inches(1.5), rh, row_title,
|
||
self.ds, size=self.tp('body_small', 12), bold=True,
|
||
color=RGBColor(0xFF, 0xFF, 0xFF),
|
||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||
# 该行步骤
|
||
row_steps = steps[ri] if ri < len(steps) else []
|
||
if isinstance(row_steps, str):
|
||
row_steps = [row_steps]
|
||
lane_w = Emu(int((cw - Inches(1.5) - Inches(0.2)
|
||
- Inches(0.3) * max(len(row_steps) - 1, 0))
|
||
/ max(len(row_steps), 1)))
|
||
for si, step in enumerate(row_steps):
|
||
sx = x0 + Inches(1.5) + Inches(0.1) + si * (lane_w + Inches(0.3))
|
||
step_parts = str(step).split('\n')
|
||
st_title = step_parts[0]
|
||
st_desc = '\n'.join(step_parts[1:])
|
||
card = add_rect(s, sx, ry, lane_w, rh, self.ds,
|
||
fill=self.ds.card_fill, radius=True,
|
||
shadow=True)
|
||
title_h = Emu(int(rh * 0.35))
|
||
# 序号徽章(左上角,小尺寸)
|
||
badge = add_rect(s, sx + Inches(0.06), ry + Inches(0.08),
|
||
Inches(0.24), Inches(0.22), self.ds,
|
||
fill=self.ds.primary, radius=True)
|
||
bt = badge.text_frame
|
||
bt.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
bp = bt.paragraphs[0]
|
||
bp.alignment = PP_ALIGN.CENTER
|
||
br = bp.add_run()
|
||
br.text = str(si + 1)
|
||
set_run_font(br, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||
self.ds.font_cn)
|
||
# 标题区(徽章右侧开始)
|
||
add_text(s, sx + Inches(0.38), ry + Inches(0.08),
|
||
lane_w - Inches(0.5), title_h - Inches(0.1),
|
||
st_title, self.ds,
|
||
size=self.tp('body_small', 12), bold=True,
|
||
color=self.ds.primary,
|
||
align=PP_ALIGN.CENTER,
|
||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.1)
|
||
if st_desc:
|
||
# 描述区:卡片下部 65%(自动换行,垂直居中)
|
||
desc_h = Emu(int(rh - title_h))
|
||
add_text(s, sx + Inches(0.08),
|
||
ry + title_h + Inches(0.04),
|
||
lane_w - Inches(0.16),
|
||
desc_h - Inches(0.1),
|
||
st_desc, self.ds,
|
||
size=self.tp('timeline_desc', 10),
|
||
color=self.ds.text_dim,
|
||
align=PP_ALIGN.CENTER,
|
||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2)
|
||
if si < len(row_steps) - 1:
|
||
add_shape(s, MSO_SHAPE.RIGHT_ARROW,
|
||
sx + lane_w + Inches(0.04),
|
||
ry + Emu(int(rh / 2)) - Inches(0.1),
|
||
Inches(0.22), Inches(0.2),
|
||
self.ds, fill=self.ds.secondary)
|
||
else:
|
||
# 横向流程模式:steps 为 ["阶段A|描述", ...]
|
||
n = len(steps)
|
||
if not n:
|
||
return
|
||
# 步骤多时自动分行(每行最多4个)
|
||
per_row = 4 if n > 4 else n
|
||
n_rows = (n + per_row - 1) // per_row
|
||
bh = Inches(min(1.3, (avail_h - 0.35 * (n_rows - 1)) / n_rows))
|
||
row_gap = Inches(0.35)
|
||
total_h = n_rows * bh + (n_rows - 1) * row_gap
|
||
by0 = y0 + Emu(int((Inches(avail_h) - total_h) / 2))
|
||
bw = Emu(int((cw - Inches(0.35) * (per_row - 1)) / per_row))
|
||
for i, step in enumerate(steps):
|
||
r_i, c_i = divmod(i, per_row)
|
||
bx = x0 + c_i * (bw + Inches(0.35))
|
||
by = by0 + r_i * (bh + row_gap)
|
||
parts = str(step).split('\n')
|
||
st_title = parts[0]
|
||
st_desc = '\n'.join(parts[1:])
|
||
card = add_rect(s, bx, by, bw, bh, self.ds,
|
||
fill=self.ds.card_fill, radius=True,
|
||
shadow=True)
|
||
# 标题区 40%
|
||
title_h = Emu(int(bh * 0.4))
|
||
# 序号徽章(左上角,小尺寸)
|
||
badge = add_rect(s, bx + Inches(0.06), by + Inches(0.08),
|
||
Inches(0.24), Inches(0.22), self.ds,
|
||
fill=self.ds.primary, radius=True)
|
||
bt = badge.text_frame
|
||
bt.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||
bp = bt.paragraphs[0]
|
||
bp.alignment = PP_ALIGN.CENTER
|
||
br = bp.add_run()
|
||
br.text = str(i + 1)
|
||
set_run_font(br, 9, True, RGBColor(0xFF, 0xFF, 0xFF),
|
||
self.ds.font_cn)
|
||
# 标题(徽章右侧开始,避免重叠)——窄卡片自动缩小字号
|
||
title_sz = 12
|
||
desc_sz = 10
|
||
if bw < Inches(1.6):
|
||
title_sz = 9
|
||
desc_sz = 8
|
||
elif bw < Inches(2.2):
|
||
title_sz = 10
|
||
desc_sz = 9
|
||
add_text(s, bx + Inches(0.38), by + Inches(0.08),
|
||
bw - Inches(0.48), title_h - Inches(0.12),
|
||
st_title, self.ds, size=title_sz, bold=True,
|
||
color=self.ds.primary, align=PP_ALIGN.CENTER,
|
||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.05)
|
||
if st_desc:
|
||
add_text(s, bx + Inches(0.08), by + title_h + Inches(0.04),
|
||
bw - Inches(0.16), bh - title_h - Inches(0.1),
|
||
st_desc, self.ds, size=desc_sz,
|
||
color=self.ds.text_dim,
|
||
align=PP_ALIGN.CENTER,
|
||
anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.15)
|
||
if c_i < per_row - 1 and i < n - 1:
|
||
add_shape(s, MSO_SHAPE.RIGHT_ARROW,
|
||
bx + bw + Inches(0.04),
|
||
by + Emu(int(bh / 2)) - Inches(0.1),
|
||
Inches(0.27), Inches(0.2),
|
||
self.ds, fill=self.ds.secondary)
|
||
|
||
# ---- quote
|
||
def r_quote(self, p, idx, total):
|
||
s = self.new()
|
||
pw = self.ds.page_w
|
||
# 左侧竖条 + 引文区(动态宽度,适配10.83in窄页面)
|
||
bar_x = self.margin
|
||
text_x = Emu(int(self.margin + Inches(0.6)))
|
||
text_w = Emu(int(pw - text_x - self.margin))
|
||
add_rect(s, bar_x, Inches(2.3), Inches(0.09), Inches(2.2),
|
||
self.ds, fill=self.ds.primary)
|
||
title = p.get("title", "")
|
||
# 引文字号按长度自适应
|
||
if len(title) > 60:
|
||
title_sz = 18
|
||
elif len(title) > 40:
|
||
title_sz = 20
|
||
else:
|
||
title_sz = 24
|
||
add_text(s, text_x, Inches(2.6), text_w, Inches(1.6),
|
||
title, self.ds, size=title_sz, bold=True,
|
||
line_spacing=1.35)
|
||
if p.get("content"):
|
||
add_text(s, text_x, Inches(4.3), text_w, Inches(1.0),
|
||
"\n".join(p["content"]), self.ds,
|
||
size=self.tp('body', 15), color=self.ds.text_dim)
|
||
if p.get("notes"):
|
||
add_text(s, text_x, Inches(5.4), text_w, Inches(0.4),
|
||
p["notes"], self.ds,
|
||
size=self.tp('timeline_title', 13), bold=True,
|
||
color=self.ds.accent)
|
||
|
||
# ---- end
|
||
def r_end(self, p, idx, total):
|
||
s = self.new()
|
||
pw = self.ds.page_w
|
||
title_w = Emu(int(pw - 2 * self.margin))
|
||
title = p.get("title", "谢谢")
|
||
# 标题字号按长度自适应(长句缩小,避免溢出)
|
||
title_len = len(title)
|
||
if title_len > 28:
|
||
title_sz = 22
|
||
elif title_len > 18:
|
||
title_sz = 26
|
||
else:
|
||
title_sz = 34
|
||
# 标题区高度也自适应(最长支持2-3行)
|
||
title_h = Inches(1.6)
|
||
add_text(s, self.margin, Inches(2.7), title_w, title_h,
|
||
title, self.ds, size=title_sz, bold=True,
|
||
align=PP_ALIGN.CENTER, line_spacing=1.3)
|
||
if p.get("content"):
|
||
add_text(s, self.margin, Inches(4.5),
|
||
Emu(int(title_w - Inches(0.5))), Inches(1.2),
|
||
"\n".join(p["content"]), self.ds, size=16,
|
||
color=self.ds.text_dim, align=PP_ALIGN.CENTER)
|
||
add_rect(s, self.margin, Inches(5.7), Inches(2.8), Pt(2.5),
|
||
self.ds, fill=self.ds.primary)
|
||
|
||
|
||
# ---------------------------------------------------------------- 内容保真验证
|
||
def verify_fidelity(plan_path, pptx_path, report_only=False):
|
||
"""验证输出PPT中用户提供的数字/术语是否原样保留"""
|
||
with open(plan_path, "r", encoding="utf-8") as f:
|
||
plan = json.load(f)
|
||
prs = Presentation(pptx_path)
|
||
# 收集源数据(数字+关键术语)
|
||
source_numbers = set()
|
||
source_text = []
|
||
def collect(obj, skip_keys=None):
|
||
skip_keys = skip_keys or {"notes", "image"}
|
||
if isinstance(obj, dict):
|
||
for k, v in obj.items():
|
||
if k in skip_keys:
|
||
continue # notes 是演讲者备注;image 是本地配图路径,均不属于内容保真范围
|
||
collect(v)
|
||
elif isinstance(obj, list):
|
||
for v in obj:
|
||
collect(v)
|
||
elif isinstance(obj, str):
|
||
source_text.append(obj)
|
||
for m in re.findall(r'-?\d+[\d,]*\.?\d*\s*[%%]?', obj):
|
||
m = m.strip()
|
||
# 排除目录编号/序号(如 "01."、"1." 紧跟标点或行首)
|
||
if re.fullmatch(r'\d{1,2}\.?\s*', m):
|
||
continue
|
||
source_numbers.add(m)
|
||
collect(plan.get("slides", []))
|
||
# 注意:plan 顶层 title 是元数据(封面等用页面级 title),不纳入术语保真检查
|
||
|
||
# 收集输出文本
|
||
out_text = []
|
||
for slide in prs.slides:
|
||
for shp in slide.shapes:
|
||
if shp.has_text_frame:
|
||
out_text.append(shp.text_frame.text)
|
||
out_all = "\n".join(out_text)
|
||
|
||
# 数字保真检查
|
||
missing = []
|
||
for num in source_numbers:
|
||
if num.replace(' ', '') not in out_all.replace(' ', ''):
|
||
missing.append(num)
|
||
# 术语检查(非纯数字的内容片段,取较长的)
|
||
term_missing = []
|
||
# 已知的合法结构变换:渲染器会拆分目录编号、去掉"-"子项前缀
|
||
def normalize(s):
|
||
s = re.sub(r'^\d{1,2}\.\s*', '', s) # 去目录编号 "01. "
|
||
s = re.sub(r'^-\s*', '', s) # 去列表前缀 "- "
|
||
s = s.replace('**', '')
|
||
return s.strip()
|
||
|
||
for t in source_text:
|
||
t = t.strip()
|
||
if len(t) < 6 or not re.search(r'[\u4e00-\u9fff\u3040-\u30ff]', t):
|
||
continue
|
||
if '\n' in t or '**' in t:
|
||
continue
|
||
norm = normalize(t)
|
||
if not norm:
|
||
continue
|
||
# 输出中必须能找到该术语的规范化形式(整体或核心子串)
|
||
out_norm = normalize(out_all)
|
||
if norm in out_all or norm in out_norm:
|
||
continue
|
||
# 宽松匹配:取前8个字符作为指纹
|
||
fingerprint = norm[:8]
|
||
if fingerprint and fingerprint in out_norm:
|
||
continue
|
||
term_missing.append(t)
|
||
|
||
# notes 写入检查:plan 中有 notes 的页面,输出备注区必须非空
|
||
missing_notes = []
|
||
plan_slides = plan.get("slides", [])
|
||
out_slides = list(prs.slides)
|
||
for idx, sp in enumerate(plan_slides):
|
||
if not sp.get("notes"):
|
||
continue
|
||
if idx < len(out_slides):
|
||
slide = out_slides[idx]
|
||
try:
|
||
if not (slide.has_notes_slide and
|
||
slide.notes_slide.notes_text_frame.text.strip()):
|
||
missing_notes.append(idx + 1)
|
||
except Exception:
|
||
missing_notes.append(idx + 1)
|
||
if report_only:
|
||
return {"missing_numbers": missing[:10],
|
||
"missing_terms": term_missing[:10],
|
||
"missing_notes": missing_notes,
|
||
"total_slides": len(prs.slides)}
|
||
return (len(missing) == 0 and len(term_missing) == 0
|
||
and len(missing_notes) == 0)
|
||
|
||
|
||
# ---------------------------------------------------------------- 主流程
|
||
def get_design(design_name="aura-dark"):
|
||
"""从注册表获取设计系统实例;未知名称回退到 aura-dark"""
|
||
cls = DESIGN_SYSTEMS.get(design_name, DesignSystem)
|
||
return cls()
|
||
|
||
|
||
def render(plan_path, output_path, design_name="aura-dark",
|
||
template_path=None):
|
||
with open(plan_path, "r", encoding="utf-8") as f:
|
||
plan = json.load(f)
|
||
# plan.json 中的 design 字段优先(未指定时用 CLI 参数)
|
||
if plan.get("design"):
|
||
design_name = plan["design"]
|
||
ds = get_design(design_name)
|
||
# 模板模式:用指定PPTX作为母版模板(继承背景/页脚/Logo)
|
||
using_template = template_path is not None
|
||
if using_template:
|
||
prs = Presentation(template_path)
|
||
# 清空模板自带的旧幻灯片(只保留母版/版式)
|
||
# 通过 drop_rel 移除旧 slide 关系 + 移除 sldIdLst 引用
|
||
from pptx.oxml.ns import qn as _qn
|
||
sldIdLst = prs.slides._sldIdLst
|
||
for sldId in list(sldIdLst):
|
||
rId = sldId.get(_qn('r:id'))
|
||
sldIdLst.remove(sldId)
|
||
if rId:
|
||
try:
|
||
prs.part.drop_rel(rId)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
prs = Presentation()
|
||
prs.slide_width = ds.page_w
|
||
prs.slide_height = ds.page_h
|
||
renderer = SlideRenderer(prs, ds, template=using_template)
|
||
slides = plan.get("slides", [])
|
||
total = len(slides)
|
||
company = plan.get("company", "")
|
||
date_text = plan.get("date", "")
|
||
notes_template = plan.get("notes_template", False)
|
||
global_confidential = plan.get("confidential", "")
|
||
for i, sp in enumerate(slides, 1):
|
||
# 顶层机密标记注入页面(页面级字段优先)
|
||
sp = dict(sp)
|
||
if "confidential" not in sp and global_confidential:
|
||
sp["confidential"] = global_confidential
|
||
renderer.render(sp, i, total, company=company,
|
||
date_text=date_text, notes_template=notes_template)
|
||
prs.save(output_path)
|
||
return total
|
||
|
||
|
||
def improve(input_path, output_path, design_name="aura-dark"):
|
||
"""美化已有PPT:统一背景、标题样式、正文排版(内容零改动)"""
|
||
ds = get_design(design_name)
|
||
prs = Presentation(input_path)
|
||
for slide in prs.slides:
|
||
# 统一背景
|
||
bg = slide.background
|
||
bg.fill.solid()
|
||
bg.fill.fore_color.rgb = ds.bg
|
||
for shp in slide.shapes:
|
||
if shp.has_text_frame and shp.text_frame.text.strip():
|
||
tf = shp.text_frame
|
||
text = tf.text
|
||
font_size = None
|
||
for p in tf.paragraphs:
|
||
for r in p.runs:
|
||
if r.font.size:
|
||
font_size = r.font.size.pt
|
||
break
|
||
if font_size:
|
||
break
|
||
size = font_size or 16
|
||
# 重设字体
|
||
for p in tf.paragraphs:
|
||
p.line_spacing = 1.3
|
||
for r in p.runs:
|
||
r.font.name = ds.font_cn
|
||
rPr = r._r.get_or_add_rPr()
|
||
for tag in ('a:ea', 'a:cs'):
|
||
el = rPr.find(qn(tag))
|
||
if el is None:
|
||
el = rPr.makeelement(qn(tag), {})
|
||
rPr.append(el)
|
||
el.set('typeface', ds.font_cn)
|
||
if r.font.size is None:
|
||
r.font.size = Pt(size)
|
||
if r.font.color is None or \
|
||
r.font.color.rgb == RGBColor(0, 0, 0):
|
||
r.font.color.rgb = ds.text
|
||
prs.save(output_path)
|
||
return len(prs.slides)
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="aura-ppt 渲染引擎")
|
||
ap.add_argument("--plan", help="plan JSON 路径")
|
||
ap.add_argument("--output", help="输出 PPTX 路径")
|
||
ap.add_argument("--improve", help="美化模式:输入 PPTX 路径")
|
||
ap.add_argument("--design", default="aura-dark", help="设计系统名称")
|
||
ap.add_argument("--template", help="模板PPTX路径(继承其母版背景/页脚/Logo)")
|
||
ap.add_argument("--verify", help="验证模式:输出 PPTX 路径")
|
||
args = ap.parse_args()
|
||
|
||
if args.verify:
|
||
if not args.plan:
|
||
print("[aura-ppt] --verify 需要 --plan 配合")
|
||
sys.exit(1)
|
||
result = verify_fidelity(args.plan, args.verify, report_only=True)
|
||
print(f"[aura-ppt] 验证完成: {result['total_slides']} 页")
|
||
if result["missing_numbers"]:
|
||
print(f"[aura-ppt] ⚠ 数字保真失败: {result['missing_numbers']}")
|
||
sys.exit(2)
|
||
if result["missing_terms"]:
|
||
print(f"[aura-ppt] ⚠ 术语保真失败: {result['missing_terms']}")
|
||
sys.exit(3)
|
||
if result.get("missing_notes"):
|
||
print(f"[aura-ppt] ⚠ 备注缺失: {result['missing_notes']}")
|
||
sys.exit(4)
|
||
print("[aura-ppt] ✅ 内容保真验证通过(数字+术语+备注全部完整)")
|
||
sys.exit(0)
|
||
|
||
if args.improve:
|
||
if not args.output:
|
||
print("[aura-ppt] 美化模式需要 --output")
|
||
sys.exit(1)
|
||
n = improve(args.improve, args.output, args.design)
|
||
print(f"[aura-ppt] 美化完成: {n} 页 → {args.output}")
|
||
sys.exit(0)
|
||
|
||
if not args.plan or not args.output:
|
||
ap.print_help()
|
||
sys.exit(1)
|
||
n = render(args.plan, args.output, args.design, args.template)
|
||
mode = "模板生成" if args.template else "生成"
|
||
print(f"[aura-ppt] {mode}完成: {n} 页 → {args.output}")
|
||
if os.path.exists(args.plan):
|
||
ok = verify_fidelity(args.plan, args.output)
|
||
if not ok:
|
||
print("[aura-ppt] ⚠ 注意: 检测到内容保真偏差,请运行 --verify 查看详情")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|