#!/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()