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 即可幂等重生成
This commit is contained in:
张宗平
2026-07-05 16:07:40 +08:00
parent 0f81edcc74
commit ea6f12e7ac
12 changed files with 1401 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
#!/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()