Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
611 lines
22 KiB
Python
611 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
"""羚牛数智中心 — 赛博朋克增强版 PPT(背景图/图示/科幻字体/动画标记)"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import shutil
|
||
import zipfile
|
||
from pathlib import Path
|
||
|
||
from lxml import etree
|
||
from pptx import Presentation
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.shapes import MSO_SHAPE
|
||
from pptx.enum.text import PP_ALIGN
|
||
from pptx.util import Inches, Pt
|
||
|
||
BASE = Path(__file__).resolve().parent.parent
|
||
IMG = BASE / "assets" / "ppt-images"
|
||
OUT = BASE / "羚牛数智中心建设规划_赛博朋克_增强版.pptx"
|
||
OUT_DESKTOP = Path("/Users/sylvawong/Desktop/羚牛数智中心建设规划_赛博朋克_增强版.pptx")
|
||
|
||
# 科幻字体(Mac 常见;若已安装 Orbitron 会自动使用)
|
||
FONT_EN = "Orbitron"
|
||
FONT_EN_FB = "Avenir Next Demi Bold"
|
||
FONT_CN = "PingFang SC Light"
|
||
FONT_CN_FB = "Heiti SC Light"
|
||
|
||
BG_DARK = RGBColor(6, 10, 28)
|
||
BG_PANEL = RGBColor(12, 18, 48)
|
||
CYAN = RGBColor(0, 245, 255)
|
||
MAGENTA = RGBColor(255, 0, 140)
|
||
PURPLE = RGBColor(140, 80, 255)
|
||
GOLD = RGBColor(255, 210, 60)
|
||
WHITE = RGBColor(235, 245, 255)
|
||
SILVER = RGBColor(160, 180, 210)
|
||
DIM = RGBColor(90, 110, 150)
|
||
OVERLAY = RGBColor(8, 12, 32)
|
||
|
||
SLIDE_W = Inches(13.333)
|
||
SLIDE_H = Inches(7.5)
|
||
|
||
# 每页登记待动画 shape_id: {slide_index: [(shape_id, anim_type)]}
|
||
ANIM_REGISTRY: dict[int, list[tuple[int, str]]] = {}
|
||
SLIDE_BG_CYCLE = ["bg-city.png", "bg-hydrogen.png", "bg-dataflow.png", "bg-city.png"]
|
||
|
||
|
||
def font_en():
|
||
return FONT_EN
|
||
|
||
|
||
def font_cn():
|
||
return FONT_CN
|
||
|
||
|
||
def set_font(p, en=False, size=16, bold=False, color=SILVER):
|
||
p.font.size = Pt(size)
|
||
p.font.bold = bold
|
||
p.font.color.rgb = color
|
||
p.font.name = font_en() if en else font_cn()
|
||
|
||
|
||
def register_anim(slide_idx: int, shape_id: int, anim: str):
|
||
ANIM_REGISTRY.setdefault(slide_idx, []).append((shape_id, anim))
|
||
|
||
|
||
def add_bg_image(slide, filename: str, overlay_alpha=0.72):
|
||
path = IMG / filename
|
||
if not path.exists():
|
||
return
|
||
pic = slide.shapes.add_picture(str(path), 0, 0, width=SLIDE_W, height=SLIDE_H)
|
||
# 暗色蒙版提升文字可读性
|
||
mask = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_W, SLIDE_H)
|
||
mask.fill.solid()
|
||
mask.fill.fore_color.rgb = OVERLAY
|
||
mask.fill.transparency = 1.0 - overlay_alpha # 0=不透明蒙版
|
||
mask.line.fill.background()
|
||
# 置底:先添加的在下,把 pic 和 mask 移到最前再 send backward - python-pptx z-order
|
||
sp_tree = slide.shapes._spTree
|
||
sp_tree.remove(pic._element)
|
||
sp_tree.insert(2, pic._element)
|
||
sp_tree.remove(mask._element)
|
||
sp_tree.insert(3, mask._element)
|
||
|
||
|
||
def add_glow_bar(slide, left, top, width, height, color):
|
||
sh = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
|
||
sh.fill.solid()
|
||
sh.fill.fore_color.rgb = color
|
||
sh.line.fill.background()
|
||
return sh
|
||
|
||
|
||
def add_corner_accents(slide):
|
||
add_glow_bar(slide, Inches(0), Inches(0), Inches(0.12), SLIDE_H, CYAN)
|
||
add_glow_bar(slide, SLIDE_W - Inches(0.08), Inches(0), Inches(0.08), SLIDE_H, MAGENTA)
|
||
|
||
|
||
def add_header(slide, slide_idx, title, subtitle=None, anim_title="fly", anim_sub="fade"):
|
||
add_corner_accents(slide)
|
||
add_glow_bar(slide, Inches(0.8), Inches(1.05), Inches(3.5), Pt(4), CYAN)
|
||
tb = slide.shapes.add_textbox(Inches(0.8), Inches(0.45), Inches(11.5), Inches(0.9))
|
||
p = tb.text_frame.paragraphs[0]
|
||
p.text = title
|
||
set_font(p, en=False, size=32, bold=True, color=WHITE)
|
||
register_anim(slide_idx, tb.shape_id, anim_title)
|
||
if subtitle:
|
||
tb2 = slide.shapes.add_textbox(Inches(0.8), Inches(1.12), Inches(11), Inches(0.5))
|
||
p2 = tb2.text_frame.paragraphs[0]
|
||
p2.text = subtitle
|
||
set_font(p2, en=True, size=13, color=CYAN)
|
||
register_anim(slide_idx, tb2.shape_id, anim_sub)
|
||
|
||
|
||
def add_bullets(slide, slide_idx, items, top=Inches(1.6)):
|
||
tb = slide.shapes.add_textbox(Inches(0.9), top, Inches(11.2), Inches(5.2))
|
||
tf = tb.text_frame
|
||
tf.word_wrap = True
|
||
for i, (text, accent) in enumerate(items):
|
||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||
p.text = "▸ " + text
|
||
set_font(p, size=16 if len(text) < 80 else 14, color=accent or SILVER)
|
||
p.space_after = Pt(10)
|
||
register_anim(slide_idx, tb.shape_id, "fade")
|
||
|
||
|
||
def add_cards(slide, slide_idx, cards, top=Inches(1.7)):
|
||
n = len(cards)
|
||
gap = Inches(0.25)
|
||
w = (Inches(11.5) - gap * (n - 1)) / n
|
||
for i, (title, body, color) in enumerate(cards):
|
||
left = Inches(0.8) + (w + gap) * i
|
||
panel = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, w, Inches(4.8))
|
||
panel.fill.solid()
|
||
panel.fill.fore_color.rgb = BG_PANEL
|
||
panel.line.color.rgb = color
|
||
panel.line.width = Pt(1.5)
|
||
register_anim(slide_idx, panel.shape_id, "fade")
|
||
tb = slide.shapes.add_textbox(left + Inches(0.2), top + Inches(0.25), w - Inches(0.4), Inches(4.3))
|
||
tf = tb.text_frame
|
||
tf.word_wrap = True
|
||
p0 = tf.paragraphs[0]
|
||
p0.text = title
|
||
set_font(p0, en=True, size=17, bold=True, color=color)
|
||
p1 = tf.add_paragraph()
|
||
p1.text = body
|
||
set_font(p1, size=12, color=SILVER)
|
||
p1.space_before = Pt(12)
|
||
|
||
|
||
def add_flow_diagram(slide, slide_idx, labels, colors, top=Inches(2.2)):
|
||
"""三步演进图示:箭头连接圆角框"""
|
||
n = len(labels)
|
||
box_w = Inches(3.2)
|
||
gap = Inches(0.55)
|
||
start_x = Inches(0.9)
|
||
for i, (lab, col) in enumerate(zip(labels, colors)):
|
||
left = start_x + (box_w + gap) * i
|
||
sh = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, box_w, Inches(1.4))
|
||
sh.fill.solid()
|
||
sh.fill.fore_color.rgb = BG_PANEL
|
||
sh.line.color.rgb = col
|
||
sh.line.width = Pt(2)
|
||
register_anim(slide_idx, sh.shape_id, "fly")
|
||
tb = slide.shapes.add_textbox(left, top + Inches(0.35), box_w, Inches(0.8))
|
||
p = tb.text_frame.paragraphs[0]
|
||
p.text = lab
|
||
p.alignment = PP_ALIGN.CENTER
|
||
set_font(p, size=14, bold=True, color=col)
|
||
if i < n - 1:
|
||
ax = left + box_w + Inches(0.08)
|
||
arr = slide.shapes.add_shape(MSO_SHAPE.RIGHT_ARROW, ax, top + Inches(0.45), gap - Inches(0.16), Inches(0.5))
|
||
arr.fill.solid()
|
||
arr.fill.fore_color.rgb = col
|
||
arr.line.fill.background()
|
||
|
||
|
||
def add_hex_diagram(slide, slide_idx, items, top=Inches(2.0)):
|
||
"""1+N+X 六边形图示"""
|
||
n = len(items)
|
||
w = Inches(3.5)
|
||
gap = Inches(0.35)
|
||
for i, (t, b, c) in enumerate(items):
|
||
left = Inches(0.85) + (w + gap) * i
|
||
hex_shape = slide.shapes.add_shape(MSO_SHAPE.HEXAGON, left, top, w, Inches(2.8))
|
||
hex_shape.fill.solid()
|
||
hex_shape.fill.fore_color.rgb = BG_PANEL
|
||
hex_shape.line.color.rgb = c
|
||
hex_shape.line.width = Pt(2)
|
||
register_anim(slide_idx, hex_shape.shape_id, "fade")
|
||
tb = slide.shapes.add_textbox(left + Inches(0.15), top + Inches(0.5), w - Inches(0.3), Inches(2))
|
||
tf = tb.text_frame
|
||
tf.word_wrap = True
|
||
p0 = tf.paragraphs[0]
|
||
p0.text = t
|
||
p0.alignment = PP_ALIGN.CENTER
|
||
set_font(p0, en=True, size=22, bold=True, color=c)
|
||
p1 = tf.add_paragraph()
|
||
p1.text = b
|
||
p1.alignment = PP_ALIGN.CENTER
|
||
set_font(p1, size=11, color=SILVER)
|
||
p1.space_before = Pt(8)
|
||
|
||
|
||
def new_slide(prs, bg_file: str):
|
||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||
add_bg_image(slide, bg_file, overlay_alpha=0.68)
|
||
return slide
|
||
|
||
|
||
def slide_title(prs, idx):
|
||
slide = new_slide(prs, "bg-city.png")
|
||
add_corner_accents(slide)
|
||
add_glow_bar(slide, Inches(1.2), Inches(3.85), Inches(10.8), Pt(2), MAGENTA)
|
||
|
||
tb = slide.shapes.add_textbox(Inches(0.9), Inches(1.7), Inches(11.5), Inches(1.2))
|
||
p = tb.text_frame.paragraphs[0]
|
||
p.text = "羚牛数智中心"
|
||
set_font(p, size=48, bold=True, color=CYAN)
|
||
p.alignment = PP_ALIGN.CENTER
|
||
register_anim(idx, tb.shape_id, "fly")
|
||
|
||
tb2 = slide.shapes.add_textbox(Inches(0.9), Inches(2.85), Inches(11.5), Inches(0.8))
|
||
p2 = tb2.text_frame.paragraphs[0]
|
||
p2.text = "建设规划报告"
|
||
set_font(p2, size=34, color=WHITE)
|
||
p2.alignment = PP_ALIGN.CENTER
|
||
register_anim(idx, tb2.shape_id, "fade")
|
||
|
||
tb3 = slide.shapes.add_textbox(Inches(0.9), Inches(4.1), Inches(11.5), Inches(0.6))
|
||
p3 = tb3.text_frame.paragraphs[0]
|
||
p3.text = "LINGNIU DIGITAL INTELLIGENCE CENTER · V1.0"
|
||
set_font(p3, en=True, size=13, color=MAGENTA)
|
||
p3.alignment = PP_ALIGN.CENTER
|
||
register_anim(idx, tb3.shape_id, "fade")
|
||
|
||
tb4 = slide.shapes.add_textbox(Inches(0.9), Inches(5.0), Inches(11.5), Inches(0.8))
|
||
p4 = tb4.text_frame.paragraphs[0]
|
||
p4.text = "羚牛氢能 One OS · 氢能交通商业操作系统"
|
||
set_font(p4, size=16, color=GOLD)
|
||
p4.alignment = PP_ALIGN.CENTER
|
||
register_anim(idx, tb4.shape_id, "fade")
|
||
|
||
|
||
def slide_architecture(prs, idx):
|
||
slide = new_slide(prs, "bg-dataflow.png")
|
||
add_header(slide, idx, "羚牛氢能 One OS · 架构示意图", "SYSTEM ARCHITECTURE", "fly", "fade")
|
||
arch = IMG / "architecture.png"
|
||
if arch.exists():
|
||
pic = slide.shapes.add_picture(
|
||
str(arch), Inches(0.55), Inches(1.45), width=Inches(12.2), height=Inches(5.7)
|
||
)
|
||
register_anim(idx, pic.shape_id, "fade")
|
||
# 叠加矢量层:核心模块框
|
||
layers = [
|
||
("应用层", "小羚羚 · 运营终端 · 客户服务", CYAN, Inches(1.0), Inches(1.55)),
|
||
("平台层", "任务调度 · 能源管理 · 资产视图", MAGENTA, Inches(4.6), Inches(1.55)),
|
||
("数据层", "主数据 · 指标仓 · AI 特征", PURPLE, Inches(8.2), Inches(1.55)),
|
||
("生态层", "API · SaaS · 产业开放", GOLD, Inches(4.6), Inches(5.85)),
|
||
]
|
||
for title, sub, col, left, top in layers:
|
||
box = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, Inches(3.8), Inches(0.95))
|
||
box.fill.solid()
|
||
box.fill.fore_color.rgb = BG_PANEL
|
||
box.line.color.rgb = col
|
||
box.line.width = Pt(1.5)
|
||
register_anim(idx, box.shape_id, "fly")
|
||
tb = slide.shapes.add_textbox(left + Inches(0.1), top + Inches(0.08), Inches(3.6), Inches(0.8))
|
||
tf = tb.text_frame
|
||
p0 = tf.paragraphs[0]
|
||
p0.text = title
|
||
set_font(p0, en=True, size=12, bold=True, color=col)
|
||
p1 = tf.add_paragraph()
|
||
p1.text = sub
|
||
set_font(p1, size=9, color=SILVER)
|
||
|
||
|
||
def build_slides(prs):
|
||
si = 0
|
||
slide_title(prs, si)
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "一、报告背景与目标", "BACKGROUND & OBJECTIVES")
|
||
add_bullets(
|
||
s,
|
||
si,
|
||
[
|
||
("业务规模扩大,人工台账与分散系统难以为继", None),
|
||
("数据分散 · 流程依赖人工 · 单点建设难复用", MAGENTA),
|
||
("建设统一「数智中心」是战略发展的必然选择", CYAN),
|
||
],
|
||
)
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "现状痛点", "CURRENT CHALLENGES")
|
||
add_cards(
|
||
s,
|
||
si,
|
||
[
|
||
("数据孤岛", "难以统一视图", CYAN),
|
||
("流程非标", "可追溯性不足", MAGENTA),
|
||
("重复建设", "难以复用扩展", PURPLE),
|
||
("复杂场景", "多能源难支撑", GOLD),
|
||
],
|
||
)
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "要解决的问题", "PROBLEMS TO SOLVE")
|
||
add_cards(
|
||
s,
|
||
si,
|
||
[
|
||
("当前必须解决", "手工台账 · 系统割裂\n任务依赖人工 · 缺乏数据支撑", CYAN),
|
||
("未来必须应对", "人效下降 · 复杂度上升\n平台不可持续 · AI 难落地", MAGENTA),
|
||
],
|
||
)
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "四大建设目标", "CORE OBJECTIVES")
|
||
add_cards(
|
||
s,
|
||
si,
|
||
[
|
||
("稳定运行", "支撑当前业务", CYAN),
|
||
("持续演进", "支持5年发展", MAGENTA),
|
||
("数据资产", "可沉淀可变现", PURPLE),
|
||
("对内对外", "降本+SaaS输出", GOLD),
|
||
],
|
||
)
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "二、战略定位", "STRATEGIC POSITIONING")
|
||
add_bullets(
|
||
s,
|
||
si,
|
||
[
|
||
("数智中心:未来3–5年核心数字基础设施", CYAN),
|
||
("长期战略资产,非单一IT项目", WHITE),
|
||
("羚牛氢能 One OS — 氢能交通商业操作系统", GOLD),
|
||
("核心产品:小羚羚统一运营与服务终端", MAGENTA),
|
||
],
|
||
)
|
||
si += 1
|
||
|
||
slide_architecture(prs, si)
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "战略阶段:三步走", "THREE-PHASE ROADMAP")
|
||
add_flow_diagram(
|
||
s,
|
||
si,
|
||
["Phase 01\n统一·标准·稳定", "Phase 02\n平台·复用·协同", "Phase 03\n智能·开放·商业"],
|
||
[CYAN, MAGENTA, GOLD],
|
||
)
|
||
si += 1
|
||
|
||
for title, sub, bullets in [
|
||
(
|
||
"Phase 01 · 统一 · 标准 · 稳定",
|
||
"FOUNDATION",
|
||
[
|
||
("业务在线、数据留痕、流程可控", CYAN),
|
||
("统一运营平台与小羚羚终端", None),
|
||
("标准化数据、流程、接口", None),
|
||
("高可用安全合规", None),
|
||
],
|
||
),
|
||
(
|
||
"Phase 02 · 平台化 · 复用 · 协同",
|
||
"PLATFORM",
|
||
[
|
||
("从有数据到用数据", MAGENTA),
|
||
("平台化抽象通用能力", None),
|
||
("复用降低边际成本", None),
|
||
("跨部门协同打通全链路", GOLD),
|
||
],
|
||
),
|
||
(
|
||
"Phase 03 · 智能化 · 开放 · 商业化",
|
||
"INTELLIGENCE",
|
||
[
|
||
("AI驱动绿色智能生态", GOLD),
|
||
("智能化自主优化", None),
|
||
("开放API与SaaS", None),
|
||
("成本中心→利润中心", CYAN),
|
||
],
|
||
),
|
||
]:
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, title, sub)
|
||
add_bullets(s, si, bullets)
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "演进逻辑", "EVOLUTION LOGIC")
|
||
add_flow_diagram(s, si, ["信息化\n0 → 1", "数字化\n1 → N", "智能化\nN → ∞"], [CYAN, MAGENTA, GOLD], top=Inches(2.4))
|
||
tb = s.shapes.add_textbox(Inches(0.8), Inches(5.4), Inches(11.5), Inches(0.8))
|
||
p = tb.text_frame.paragraphs[0]
|
||
p.text = "成本中心 ──▶ 利润中心 · 驱动绿色交通变革"
|
||
set_font(p, size=18, color=WHITE)
|
||
p.alignment = PP_ALIGN.CENTER
|
||
register_anim(si, tb.shape_id, "fade")
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "三、整体建设方案", "IMPLEMENTATION")
|
||
add_bullets(
|
||
s,
|
||
si,
|
||
[
|
||
("分阶段实施,按业务节奏推进", CYAN),
|
||
("基础优先:系统能力先于智能", None),
|
||
("核心内控:需求架构数据内控", None),
|
||
("敏捷迭代:MVP快速验证", None),
|
||
],
|
||
)
|
||
si += 1
|
||
|
||
s = new_slide(prs, SLIDE_BG_CYCLE[si % 4])
|
||
add_header(s, si, "「1 + N + X」核心理念", "1 + N + X")
|
||
add_hex_diagram(
|
||
s,
|
||
si,
|
||
[
|
||
("1", "车辆/设备/能源\n唯一主键", CYAN),
|
||
("N", "电·氢·充换氢\n多能源", MAGENTA),
|
||
("X", "运营·物流·金融\nESG·调度", GOLD),
|
||
],
|
||
)
|
||
si += 1
|
||
|
||
s = new_slide(prs, "bg-hydrogen.png")
|
||
add_corner_accents(s)
|
||
add_glow_bar(s, Inches(2), Inches(3.2), Inches(9.3), Pt(3), CYAN)
|
||
tb = s.shapes.add_textbox(Inches(0.9), Inches(2.5), Inches(11.5), Inches(1.5))
|
||
p = tb.text_frame.paragraphs[0]
|
||
p.text = "羚牛氢能 One OS"
|
||
set_font(p, size=44, bold=True, color=CYAN)
|
||
p.alignment = PP_ALIGN.CENTER
|
||
register_anim(si, tb.shape_id, "fly")
|
||
tb2 = s.shapes.add_textbox(Inches(0.9), Inches(3.8), Inches(11.5), Inches(1))
|
||
p2 = tb2.text_frame.paragraphs[0]
|
||
p2.text = "连接产业各方 · 驱动绿色交通变革"
|
||
set_font(p2, size=20, color=WHITE)
|
||
p2.alignment = PP_ALIGN.CENTER
|
||
register_anim(si, tb2.shape_id, "fade")
|
||
|
||
|
||
# ---------- 动画注入(OOXML)----------
|
||
|
||
NSMAP = {
|
||
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||
}
|
||
|
||
|
||
def _timing_xml(shape_id: int, preset_id: int, preset_subtype: int, dur_ms: int) -> etree._Element:
|
||
"""生成单 shape 入场动画 timing 片段(fade preset 10, fly preset 2)"""
|
||
# 简化:使用 preset 动画
|
||
return etree.fromstring(
|
||
f"""
|
||
<p:par xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
|
||
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||
<p:cTn id="{shape_id + 1000}" presetID="{preset_id}" presetClass="entr"
|
||
presetSubtype="{preset_subtype}" fill="hold" nodeType="clickEffect">
|
||
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
|
||
<p:childTnLst>
|
||
<p:set>
|
||
<p:cBhvr>
|
||
<p:cTn id="{shape_id + 2000}" dur="1" fill="hold">
|
||
<p:stCondLst><p:cond delay="0"/></p:stCondLst>
|
||
</p:cTn>
|
||
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
|
||
<p:attributeNameLst><p:attributeName>style.visibility</p:attributeName></p:attributeNameLst>
|
||
</p:cBhvr>
|
||
<p:to><p:strVal val="visible"/></p:to>
|
||
</p:set>
|
||
<p:animEffect transition="in" filter="fade({dur_ms})">
|
||
<p:cBhvr>
|
||
<p:cTn id="{shape_id + 3000}" dur="{dur_ms}"/>
|
||
<p:tgtEl><p:spTgt spid="{shape_id}"/></p:tgtEl>
|
||
</p:cBhvr>
|
||
</p:animEffect>
|
||
</p:childTnLst>
|
||
</p:cTn>
|
||
</p:par>
|
||
""".encode()
|
||
)
|
||
|
||
|
||
def inject_animations(pptx_path: Path, registry: dict[int, list[tuple[int, str]]]):
|
||
"""向 pptx 注入基础淡入/飞入动画(PowerPoint 2016+)"""
|
||
tmp = pptx_path.parent / "_pptx_anim_tmp"
|
||
if tmp.exists():
|
||
shutil.rmtree(tmp)
|
||
with zipfile.ZipFile(pptx_path, "r") as z:
|
||
z.extractall(tmp)
|
||
|
||
slides_dir = tmp / "ppt" / "slides"
|
||
slide_files = sorted(slides_dir.glob("slide*.xml"), key=lambda p: int(p.stem.replace("slide", "")))
|
||
|
||
for slide_idx, anims in registry.items():
|
||
if slide_idx >= len(slide_files):
|
||
continue
|
||
slide_path = slide_files[slide_idx]
|
||
tree = etree.parse(str(slide_path))
|
||
root = tree.getroot()
|
||
|
||
# 移除已有 timing
|
||
for old in root.findall("p:timing", NSMAP):
|
||
root.remove(old)
|
||
|
||
child_tn_lst = etree.Element("{http://schemas.openxmlformats.org/presentationml/2006/main}childTnLst")
|
||
for spid, anim_type in anims:
|
||
if anim_type == "fly":
|
||
# fly from bottom: presetID 2 subtype 4
|
||
node = _timing_xml(spid, 2, 4, 500)
|
||
else:
|
||
node = _timing_xml(spid, 10, 0, 400)
|
||
child_tn_lst.append(node)
|
||
|
||
timing = etree.Element("{http://schemas.openxmlformats.org/presentationml/2006/main}timing")
|
||
tn_lst = etree.SubElement(timing, "{http://schemas.openxmlformats.org/presentationml/2006/main}tnLst")
|
||
par = etree.SubElement(tn_lst, "{http://schemas.openxmlformats.org/presentationml/2006/main}par")
|
||
ctn = etree.SubElement(
|
||
par,
|
||
"{http://schemas.openxmlformats.org/presentationml/2006/main}cTn",
|
||
id="1",
|
||
dur="indefinite",
|
||
restart="never",
|
||
nodeType="tmRoot",
|
||
)
|
||
ctn_child = etree.SubElement(
|
||
ctn, "{http://schemas.openxmlformats.org/presentationml/2006/main}childTnLst"
|
||
)
|
||
seq = etree.SubElement(
|
||
ctn_child,
|
||
"{http://schemas.openxmlformats.org/presentationml/2006/main}seq",
|
||
concurrent="1",
|
||
nextAc="seek",
|
||
)
|
||
seq_ctn = etree.SubElement(
|
||
seq,
|
||
"{http://schemas.openxmlformats.org/presentationml/2006/main}cTn",
|
||
id="2",
|
||
dur="indefinite",
|
||
nodeType="mainSeq",
|
||
)
|
||
seq_child = etree.SubElement(
|
||
seq_ctn, "{http://schemas.openxmlformats.org/presentationml/2006/main}childTnLst"
|
||
)
|
||
for child in child_tn_lst:
|
||
wrap = etree.SubElement(
|
||
seq_child, "{http://schemas.openxmlformats.org/presentationml/2006/main}par"
|
||
)
|
||
wrap_ctn = etree.SubElement(
|
||
wrap,
|
||
"{http://schemas.openxmlformats.org/presentationml/2006/main}cTn",
|
||
fill="hold",
|
||
)
|
||
wrap_child = etree.SubElement(
|
||
wrap_ctn,
|
||
"{http://schemas.openxmlformats.org/presentationml/2006/main}childTnLst",
|
||
)
|
||
wrap_child.append(child)
|
||
|
||
root.append(timing)
|
||
tree.write(str(slide_path), xml_declaration=True, encoding="UTF-8", standalone=True)
|
||
|
||
out_zip = pptx_path.parent / "_pptx_anim_out.pptx"
|
||
if out_zip.exists():
|
||
out_zip.unlink()
|
||
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as zout:
|
||
for fp in tmp.rglob("*"):
|
||
if fp.is_file():
|
||
zout.write(fp, fp.relative_to(tmp))
|
||
shutil.move(str(out_zip), str(pptx_path))
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
|
||
def main():
|
||
if not IMG.exists():
|
||
raise SystemExit(f"缺少图片资源目录: {IMG}")
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = SLIDE_W
|
||
prs.slide_height = SLIDE_H
|
||
build_slides(prs)
|
||
prs.save(OUT)
|
||
|
||
try:
|
||
inject_animations(OUT, ANIM_REGISTRY)
|
||
anim_note = "已注入标题飞入/内容淡入动画"
|
||
except Exception as e:
|
||
anim_note = f"动画注入部分失败({e}),可在PPT中全选标题批量添加「飞入/淡入」"
|
||
|
||
shutil.copy(OUT, OUT_DESKTOP)
|
||
print(f"已生成: {OUT}")
|
||
print(f"已复制: {OUT_DESKTOP}")
|
||
print(f"共 {len(prs.slides)} 页 | {anim_note}")
|
||
print(f"字体: 英文 {font_en()} / 中文 {font_cn()}(若 Orbitron 未安装将回退系统字体)")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|