ea6f12e7ac
- 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 即可幂等重生成
240 lines
8.2 KiB
Python
240 lines
8.2 KiB
Python
#!/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()
|