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