Files

1871 lines
81 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()