Files
scuc-qt-course/tools/teaching_ppt/gen.py
T
张宗平 ea6f12e7ac PPT 版本幻灯片:生成器 + 5 天讲稿备注(已去 AI 味)
- tools/teaching_ppt/gen.py:python-pptx 生成引擎(页面内容与讲稿数据分离,
  幂等可重生成);tools/teaching_ppt/day1..5.py:每天的页面+讲稿数据
- docs/teaching/ppt/day1..5.pptx:5 份 PPT 产物,页面内容对应 slides/*.html,
  详细讲稿(建议时长/讲法/演示/互动/过渡)只写入演讲者备注,不进主页面
- 讲稿去 AI 味:破折号过度堆砌 152 处砍到 6 处(7.8‰ → 0.3‰)、
  升华腔(分水岭/点燃探索欲)清零、重复词(现场 26→11)替换;
  保留教学口语生动感(你们/当堂/点破/埋点/回指)与【过渡】悬念钩子
- 生成方式:python-pptx 在隔离 venv 装,不影响系统 Python;
  改 day*.py 的 notes 字段后重跑 gen.py 即可幂等重生成
2026-07-05 16:08:13 +08:00

240 lines
8.2 KiB
Python
Raw 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""教学幻灯片 PPT 生成器(4 天讲授 + Day5 综合案例)—— 手工维护,非 wiki 生成产物。
- 数据:同目录 day1.py … day5.py(每页一个 dict
- 产物:docs/teaching/ppt/day{1..5}.pptx,页面内容与 docs/teaching/slides/*.html 对应
- 讲解说明文字(含建议时长/讲法/演示/互动/过渡)只写入**演讲者备注**,不进主页面
用法(python-pptx 不在系统 Python 里,用任一装了它的解释器):
python3 -m venv ~/.venv-pptx && ~/.venv-pptx/bin/pip install python-pptx
~/.venv-pptx/bin/python tools/teaching_ppt/gen.py
"""
import importlib
import math
import os
import sys
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.text import MSO_ANCHOR
from pptx.oxml.ns import qn
from pptx.util import Inches, Pt
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT_DIR = os.path.join(ROOT, 'docs', 'teaching', 'ppt')
FONT = 'Microsoft YaHei'
MONO = 'Consolas'
# (填充, 边框, 标题色)
BOX_STYLE = {
'concept': (RGBColor(0xE8, 0xF5, 0xE9), RGBColor(0x2F, 0x85, 0x5A), RGBColor(0x22, 0x54, 0x3D)),
'pitfall': (RGBColor(0xFD, 0xEC, 0xEA), RGBColor(0xC5, 0x30, 0x30), RGBColor(0x82, 0x27, 0x27)),
'info': (RGBColor(0xEB, 0xF8, 0xFF), RGBColor(0x2B, 0x6C, 0xB0), RGBColor(0x2A, 0x43, 0x65)),
'task': (RGBColor(0xFF, 0xFA, 0xEB), RGBColor(0xB7, 0x79, 0x1F), RGBColor(0x74, 0x4D, 0x0B)),
}
BOX_DEFAULT_HEADING = {'concept': '核心概念', 'pitfall': '常见的坑', 'info': '说明', 'task': '任务卡'}
TEXT_DARK = RGBColor(0x1A, 0x20, 0x2C)
TEXT_GRAY = RGBColor(0x71, 0x80, 0x96)
CODE_BG = RGBColor(0x1E, 0x1E, 0x1E)
CODE_FG = RGBColor(0xD4, 0xD4, 0xD4)
ML = 0.45 # 左右边距 (inch)
W = 13.333 - 2 * ML
def set_font(run, name=FONT, size=None, bold=None, color=None, italic=None):
f = run.font
f.name = name
if size is not None:
f.size = Pt(size)
if bold is not None:
f.bold = bold
if italic is not None:
f.italic = italic
if color is not None:
f.color.rgb = color
# 东亚字体要单独设置(python-pptx 的 .name 只设 latin
rpr = run._r.get_or_add_rPr()
ea = rpr.find(qn('a:ea'))
if ea is None:
ea = rpr.makeelement(qn('a:ea'), {})
rpr.append(ea)
ea.set('typeface', name)
def add_textbox(slide, left, top, width, height):
tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
tf = tb.text_frame
tf.word_wrap = True
tf.margin_left = tf.margin_right = Pt(4)
tf.margin_top = tf.margin_bottom = Pt(2)
return tb, tf
def wrap_lines(text, per_line):
return max(1, math.ceil(len(text) / per_line))
def add_box(slide, kind, heading, lines, top):
"""彩色概念/坑/说明卡,返回其高度(inch)。"""
fill, border, head_color = BOX_STYLE[kind]
body_h = sum(wrap_lines(t, 62) for t in lines) * 0.28
h = 0.42 + body_h + 0.10
shape = slide.shapes.add_shape(1, Inches(ML), Inches(top), Inches(W), Inches(h)) # 1 = 矩形
shape.fill.solid()
shape.fill.fore_color.rgb = fill
shape.line.color.rgb = border
shape.line.width = Pt(1.2)
shape.shadow.inherit = False
tf = shape.text_frame
tf.word_wrap = True
tf.margin_left = Pt(10)
tf.margin_right = Pt(10)
tf.margin_top = Pt(5)
tf.margin_bottom = Pt(4)
tf.vertical_anchor = MSO_ANCHOR.TOP
p = tf.paragraphs[0]
r = p.add_run()
r.text = heading or BOX_DEFAULT_HEADING[kind]
set_font(r, size=14, bold=True, color=head_color)
for line in lines:
p = tf.add_paragraph()
p.space_before = Pt(3)
r = p.add_run()
r.text = '' + line
set_font(r, size=13, color=TEXT_DARK)
return h
def add_code(slide, code, top):
lines = code.rstrip('\n').split('\n')
h = 0.26 + len(lines) * 0.245
shape = slide.shapes.add_shape(1, Inches(ML), Inches(top), Inches(W), Inches(h))
shape.fill.solid()
shape.fill.fore_color.rgb = CODE_BG
shape.line.fill.background()
shape.shadow.inherit = False
tf = shape.text_frame
tf.word_wrap = False
tf.margin_left = Pt(12)
tf.margin_top = Pt(6)
first = True
for line in lines:
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
r = p.add_run()
r.text = line if line else ' '
set_font(r, name=MONO, size=12, color=CODE_FG)
return h
def set_notes(slide, spec):
tf = slide.notes_slide.notes_text_frame
tf.clear()
minutes = spec.get('minutes')
segment = spec.get('segment', '')
header = f"⏱ 建议 {minutes} 分钟 · {segment}" if minutes else segment
paras = ([header] if header.strip(' ·') else []) + spec.get('notes', '').strip().split('\n')
first = True
for para in paras:
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
r = p.add_run()
r.text = para
set_font(r, size=12, bold=(first and bool(minutes)))
def build_title_slide(prs, spec):
slide = prs.slides.add_slide(prs.slide_layouts[6])
_, tf = add_textbox(slide, ML, 0.35, W, 0.35)
r = tf.paragraphs[0].add_run()
r.text = spec.get('badge', '')
set_font(r, size=12, color=TEXT_GRAY)
_, tf = add_textbox(slide, ML, 1.5, W, 1.0)
r = tf.paragraphs[0].add_run()
r.text = spec['title']
set_font(r, size=40, bold=True, color=TEXT_DARK)
_, tf = add_textbox(slide, ML, 2.6, W, 0.6)
r = tf.paragraphs[0].add_run()
r.text = spec.get('subtitle', '')
set_font(r, size=20, color=RGBColor(0x2B, 0x6C, 0xB0))
top = 3.5
_, tf = add_textbox(slide, ML, top, W, 2.6)
first = True
for b in spec.get('bullets', []):
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
p.space_before = Pt(6)
r = p.add_run()
r.text = '· ' + b
set_font(r, size=16, color=TEXT_DARK)
if spec.get('source'):
_, tf = add_textbox(slide, ML, 6.95, W, 0.4)
r = tf.paragraphs[0].add_run()
r.text = spec['source']
set_font(r, size=11, color=TEXT_GRAY)
set_notes(slide, spec)
def build_content_slide(prs, spec, deck, idx):
slide = prs.slides.add_slide(prs.slide_layouts[6])
_, tf = add_textbox(slide, ML, 0.18, W, 0.32)
r = tf.paragraphs[0].add_run()
r.text = spec.get('badge', '')
set_font(r, size=11, color=TEXT_GRAY)
_, tf = add_textbox(slide, ML, 0.5, W, 0.75)
r = tf.paragraphs[0].add_run()
r.text = spec['title']
set_font(r, size=27, bold=True, color=TEXT_DARK)
top = 1.35
for b in spec.get('bullets', []):
_, tf = add_textbox(slide, ML, top, W, 0.4)
r = tf.paragraphs[0].add_run()
r.text = '· ' + b
set_font(r, size=15, color=TEXT_DARK)
top += wrap_lines(b, 56) * 0.34 + 0.04
if spec.get('code'):
top += 0.06
top += add_code(slide, spec['code'], top) + 0.14
for box in spec.get('boxes', []):
kind, heading, lines = box
top += add_box(slide, kind, heading, lines, top) + 0.14
if spec.get('source'):
_, tf = add_textbox(slide, ML, 7.02, W, 0.35)
r = tf.paragraphs[0].add_run()
r.text = spec['source']
set_font(r, size=10.5, color=TEXT_GRAY)
if top > 7.05:
print(f' [overflow] {deck}{idx}页「{spec["title"]}」内容高度 {top:.2f}in > 7.05in,请精简')
set_notes(slide, spec)
def build_deck(mod_name):
mod = importlib.import_module(mod_name)
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
for i, spec in enumerate(mod.SLIDES, 1):
if spec.get('kind') == 'title':
build_title_slide(prs, spec)
else:
build_content_slide(prs, spec, mod_name, i)
out = os.path.join(OUT_DIR, f'{mod_name}.pptx')
prs.save(out)
total = sum(float(s.get('minutes', 0) or 0) for s in mod.SLIDES)
print(f'{out}: {len(mod.SLIDES)} 页, 备注时长合计 ≈ {total:.0f} 分钟')
def main():
os.makedirs(OUT_DIR, exist_ok=True)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
for n in range(1, 6):
build_deck(f'day{n}')
if __name__ == '__main__':
main()