中期成果提交:PPT自动生成 Agent(感知-规划-行动-记忆闭环 + aura-ppt 渲染引擎 + Web 交互界面 + 测试用例 + AI 使用日志)

This commit is contained in:
T1-AISUYISIN
2026-08-31 09:53:11 +08:00
commit 883a89682b
50 changed files with 10355 additions and 0 deletions
+1870
View File
@@ -0,0 +1,1870 @@
# -*- 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.5blue/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
# 设置渐变填充(通过 XMLgradFill
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)
# 插入到 spPrfill 应位于 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()