@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/asset_catalog.py +159 -0
  4. package/assets/base_template.pptx +0 -0
  5. package/assets/cover_deco.png +0 -0
  6. package/assets/logo.png +0 -0
  7. package/assets/slide2_right.png +0 -0
  8. package/assets/theme_apricot.jpg +0 -0
  9. package/assets/theme_blush.jpg +0 -0
  10. package/assets/theme_coral.jpg +0 -0
  11. package/assets/theme_glow.jpg +0 -0
  12. package/assets/theme_mauve.jpg +0 -0
  13. package/assets/theme_peach.jpg +0 -0
  14. package/assets/theme_rose.jpg +0 -0
  15. package/assets/theme_sunset.jpg +0 -0
  16. package/assets/topright_deco.png +0 -0
  17. package/build_deck.py +2082 -0
  18. package/charts.py +329 -0
  19. package/check.py +236 -0
  20. package/config.json +5 -0
  21. package/contact_sheet.py +88 -0
  22. package/cordis.patch.yml +457 -0
  23. package/deck_baseline.py +335 -0
  24. package/delivery.py +143 -0
  25. package/density-recipe.md +110 -0
  26. package/example.json +26 -0
  27. package/formula.py +125 -0
  28. package/gen_image.py +395 -0
  29. package/generate_consensus.py +892 -0
  30. package/generate_ppt.py +1356 -0
  31. package/grasp_evidence.py +150 -0
  32. package/image_check.py +148 -0
  33. package/image_fetch.py +253 -0
  34. package/ingest.py +93 -0
  35. package/media.py +470 -0
  36. package/package.json +68 -4
  37. package/preset.yml +2 -0
  38. package/quality_check.py +196 -0
  39. package/render_ppt.py +91 -0
  40. package/requirements.txt +7 -0
  41. package/restyle.py +80 -0
  42. package/run_pptfast.sh +27 -0
  43. package/run_py.sh +43 -0
  44. package/slide_ops.py +160 -0
  45. package/slide_patch.py +153 -0
  46. package/speaker_notes.py +245 -0
  47. package/style-recipes.md +166 -0
  48. package/video_gif.py +164 -0
  49. package/vision_check.py +79 -0
@@ -0,0 +1,892 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 庄重学术风 PPT 生成器 —— 藏蓝 #1F3A5F 主色 + 金 #C9A227 辅色 + 白/浅灰背景 + 微软雅黑,少图多几何。
4
+ 适合机构/学术/医学等正式汇报场景。
5
+ """
6
+ import sys, os, json
7
+ from pptx import Presentation
8
+ from pptx.util import Inches, Pt, Emu
9
+ from pptx.dml.color import RGBColor
10
+ from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
11
+ from pptx.enum.shapes import MSO_SHAPE
12
+ from pptx.oxml.ns import qn
13
+
14
+
15
+ def _load_config():
16
+ """读取本目录 config.json(可选),失败返回空 dict。"""
17
+ p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
18
+ try:
19
+ return json.load(open(p, encoding='utf-8'))
20
+ except Exception:
21
+ return {}
22
+
23
+
24
+ def _resolve_path(p):
25
+ """把 config 里的相对路径解析到脚本所在目录;空值返回空串。"""
26
+ if not p:
27
+ return ''
28
+ if os.path.isabs(p):
29
+ return p
30
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), p)
31
+
32
+ # ---------- 国企风格配色(藏蓝 + 金 + 中国红,庄重大气) ----------
33
+ TEAL = RGBColor(0x1F, 0x3A, 0x5F) # 主色 藏蓝
34
+ TEAL_D = RGBColor(0x16, 0x2F, 0x4A) # 藏蓝(暗,标题用)
35
+ AMBER = RGBColor(0xC9, 0xA2, 0x27) # 辅色 金色
36
+ AMBER_L = RGBColor(0xF6, 0xF0, 0xE0) # 浅金底
37
+ RED = RGBColor(0xC0, 0x39, 0x2B) # 强调 中国红
38
+ DARK = RGBColor(0x2B, 0x2B, 0x2B) # 正文深灰
39
+ GREY = RGBColor(0x3D, 0x4C, 0x5E) # 次要深蓝灰
40
+ LIGHT = RGBColor(0xEE, 0xF1, 0xF4) # 浅灰底
41
+ WHITE = RGBColor(0xFF, 0xFF, 0xFF)
42
+ TEAL_L = RGBColor(0xE6, 0xEC, 0xF2) # 浅蓝灰底
43
+
44
+ TITLE_FONT = "微软雅黑"
45
+ BODY_FONT = "微软雅黑"
46
+ EN_FONT = "Arial"
47
+
48
+ SLIDE_W = Inches(13.333)
49
+ SLIDE_H = Inches(7.5)
50
+
51
+ FOOTER = "" # 页脚文字默认留空;可在 spec 的 meta.footer 里指定
52
+
53
+ # ---------- 基础 ----------
54
+ def _set_run(run, text, font=BODY_FONT, size=16, bold=False, color=DARK, italic=False):
55
+ run.text = text
56
+ f = run.font
57
+ f.name = EN_FONT # latin(英文/数字)
58
+ f.size = Pt(size)
59
+ f.bold = bold
60
+ f.italic = italic
61
+ f.color.rgb = color
62
+ rPr = run._r.get_or_add_rPr()
63
+ ea = rPr.find(qn('a:ea'))
64
+ if ea is None:
65
+ ea = rPr.makeelement(qn('a:ea'), {})
66
+ rPr.append(ea)
67
+ ea.set('typeface', font) # ea(中文)
68
+
69
+ def _tb(slide, x, y, w, h, wrap=True):
70
+ tb = slide.shapes.add_textbox(x, y, w, h)
71
+ tf = tb.text_frame
72
+ tf.word_wrap = wrap
73
+ for m in ('margin_left','margin_right','margin_top','margin_bottom'):
74
+ setattr(tf, m, 0)
75
+ return tb, tf
76
+
77
+ def _para(tf, text, font=BODY_FONT, size=16, bold=False, color=DARK,
78
+ align=PP_ALIGN.LEFT, first=False, space_after=6, line_spacing=1.2):
79
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
80
+ p.alignment = align
81
+ p.space_after = Pt(space_after)
82
+ p.line_spacing = line_spacing
83
+ r = p.add_run()
84
+ _set_run(r, text, font=font, size=size, bold=bold, color=color)
85
+ return p
86
+
87
+ def _rect(slide, x, y, w, h, fill=None, line=None, line_w=1.0):
88
+ shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
89
+ if fill is None:
90
+ shp.fill.background()
91
+ else:
92
+ shp.fill.solid(); shp.fill.fore_color.rgb = fill
93
+ if line is None:
94
+ shp.line.fill.background()
95
+ else:
96
+ shp.line.color.rgb = line; shp.line.width = Pt(line_w)
97
+ shp.shadow.inherit = False
98
+ return shp
99
+
100
+ def _round_rect(slide, x, y, w, h, fill=None, line=None, radius=0.06):
101
+ shp = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
102
+ try:
103
+ shp.adjustments[0] = radius
104
+ except Exception:
105
+ pass
106
+ if fill is None:
107
+ shp.fill.background()
108
+ else:
109
+ shp.fill.solid(); shp.fill.fore_color.rgb = fill
110
+ if line is None:
111
+ shp.line.fill.background()
112
+ else:
113
+ shp.line.color.rgb = line; shp.line.width = Pt(1)
114
+ shp.shadow.inherit = False
115
+ return shp
116
+
117
+ def _shape(slide, kind, x, y, w, h, fill=None, line=None):
118
+ shp = slide.shapes.add_shape(kind, x, y, w, h)
119
+ if fill is None:
120
+ shp.fill.background()
121
+ else:
122
+ shp.fill.solid(); shp.fill.fore_color.rgb = fill
123
+ if line is None:
124
+ shp.line.fill.background()
125
+ else:
126
+ shp.line.color.rgb = line; shp.line.width = Pt(1)
127
+ shp.shadow.inherit = False
128
+ return shp
129
+
130
+ def _blank(prs):
131
+ """选接近空白的版式(模板背景由母版继承)。"""
132
+ for ly in prs.slide_layouts:
133
+ if 'blank' in (ly.name or '').lower() or '空白' in (ly.name or ''):
134
+ return ly
135
+ return prs.slide_layouts[-1]
136
+
137
+ BASE_BG = RGBColor(0xF6, 0xF7, 0xF9) # 极浅灰底(覆盖模板装饰底纹,白色卡片可浮起)
138
+
139
+ def _background(slide):
140
+ """干净浅蓝背景(覆盖模板复杂底纹,保留浅蓝基调,避免装饰干扰内容)。"""
141
+ _rect(slide, 0, 0, SLIDE_W, SLIDE_H, fill=BASE_BG)
142
+
143
+
144
+ def _logo_path():
145
+ """logo 图片路径:config.json 的 logo → 内置 assets/logo.png。"""
146
+ p = _resolve_path(_load_config().get('logo', ''))
147
+ if p:
148
+ return p
149
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'logo.png')
150
+
151
+ LOGO_PATH = _logo_path()
152
+
153
+ def _add_logo(slide):
154
+ """左上角 logo(清晰、不被遮挡);仅当 LOGO_PATH 存在时放置。"""
155
+ if os.path.exists(LOGO_PATH):
156
+ from PIL import Image
157
+ im = Image.open(LOGO_PATH)
158
+ iw, ih = im.size
159
+ h = Inches(0.46)
160
+ w = Emu(int(iw / ih * 0.46 * 914400))
161
+ slide.shapes.add_picture(LOGO_PATH, Inches(0.42), Inches(0.3), width=w, height=h)
162
+
163
+ def _new_slide(prs):
164
+ """新建空白页,用极浅灰底覆盖模板装饰底纹,得到干净学术背景。"""
165
+ s = prs.slides.add_slide(_blank(prs))
166
+ _background(s)
167
+ _add_logo(s) # 可选 logo:仅当 LOGO_PATH 存在时自动置于左上角
168
+ return s
169
+
170
+ # ---------- 页眉/页脚 ----------
171
+ def _header(slide, title, chapter=None, page=None):
172
+ # 章节标签(下移避开模板左上角 logo)
173
+ if chapter:
174
+ tb, tf = _tb(slide, Inches(0.5), Inches(0.98), Inches(11.0), Inches(0.3))
175
+ _para(tf, chapter, size=13, bold=True, color=AMBER, first=True, space_after=0)
176
+ y = Inches(1.24)
177
+ _rect(slide, Inches(0.5), y, Inches(0.09), Inches(0.62), fill=TEAL)
178
+ tb, tf = _tb(slide, Inches(0.72), y, Inches(11.6), Inches(0.7))
179
+ _para(tf, title, size=30, bold=True, color=TEAL_D, first=True, space_after=0)
180
+ # 标题下方金/红菱形点缀(国企风格三色点缀)
181
+ _shape(slide, MSO_SHAPE.DIAMOND, Inches(0.47), Inches(1.9), Inches(0.13), Inches(0.13), fill=AMBER)
182
+ _shape(slide, MSO_SHAPE.DIAMOND, Inches(0.66), Inches(1.9), Inches(0.13), Inches(0.13), fill=RED)
183
+ _footer(slide, page)
184
+
185
+ def _footer(slide, page=None):
186
+ # 底部浅灰条垫底,页脚文字清晰
187
+ _rect(slide, 0, Inches(7.02), SLIDE_W, Inches(0.48), fill=LIGHT)
188
+ y = Inches(7.08)
189
+ _rect(slide, Inches(0.5), y, Inches(12.33), Pt(1), fill=RGBColor(0xD8, 0xDE, 0xE4))
190
+ tb, tf = _tb(slide, Inches(0.5), Inches(7.16), Inches(11.0), Inches(0.3))
191
+ _para(tf, FOOTER, size=10.5, color=RGBColor(0x31, 0x3F, 0x4E), first=True, space_after=0)
192
+ if page is not None:
193
+ tb2, tf2 = _tb(slide, Inches(12.3), Inches(7.16), Inches(0.6), Inches(0.3))
194
+ _para(tf2, str(page), size=10.5, color=RGBColor(0x31, 0x3F, 0x4E), align=PP_ALIGN.RIGHT, first=True, space_after=0)
195
+
196
+ def _icon(slide, x, y, idx=0, size=0.16, kind='circle'):
197
+ colors = [TEAL, AMBER, TEAL_D]
198
+ c = colors[idx % 3]
199
+ if kind == 'diamond':
200
+ shp = _shape(slide, MSO_SHAPE.DIAMOND, x, y, Inches(size), Inches(size), fill=c)
201
+ elif kind == 'square':
202
+ shp = _shape(slide, MSO_SHAPE.RECTANGLE, x, y, Inches(size), Inches(size), fill=c)
203
+ else:
204
+ shp = _shape(slide, MSO_SHAPE.OVAL, x, y, Inches(size), Inches(size), fill=c)
205
+ return shp
206
+
207
+ def _bullet(slide, x, y, w, text, idx=0, size=16, color=DARK, kind='circle'):
208
+ _icon(slide, x, y + Inches(0.05), idx, 0.14, kind)
209
+ tb, tf = _tb(slide, x + Inches(0.28), y, w - Inches(0.28), Inches(0.5))
210
+ _para(tf, text, size=size, color=color, first=True, space_after=0, line_spacing=1.15)
211
+ return tf
212
+
213
+ IMG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'output', 'img')
214
+
215
+ def _add_framed_image(slide, name, x, y, w, h, caption=None):
216
+ """插入切题插图:等比居中,不加边框(自然融入)。"""
217
+ path = os.path.join(IMG_DIR, name + '.jpg')
218
+ if not os.path.exists(path):
219
+ return
220
+ from PIL import Image
221
+ im = Image.open(path)
222
+ iw, ih = im.size
223
+ ratio = min(w / iw, h / ih)
224
+ pw, ph = Emu(int(iw * ratio)), Emu(int(ih * ratio))
225
+ px = Emu(int(x + (w - pw) / 2)); py = Emu(int(y + (h - ph) / 2))
226
+ slide.shapes.add_picture(path, px, py, width=pw, height=ph)
227
+
228
+ def _gradient_rect(slide, x, y, w, h, rgb1, rgb2, angle=90, a1=100, a2=0):
229
+ """线性渐变矩形(rgb 为 'RRGGBB',a 为不透明度 0-100,angle 度)。"""
230
+ shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
231
+ shp.line.fill.background(); shp.shadow.inherit = False
232
+ spPr = shp._element.spPr
233
+ solid = spPr.find(qn('a:solidFill'))
234
+ grad = spPr.makeelement(qn('a:gradFill'), {})
235
+ gsLst = spPr.makeelement(qn('a:gsLst'), {})
236
+ for pos, rgb, a in ((0, rgb1, a1), (100000, rgb2, a2)):
237
+ gs = spPr.makeelement(qn('a:gs'), {'pos': str(pos)})
238
+ srgb = spPr.makeelement(qn('a:srgbClr'), {'val': rgb})
239
+ if a < 100:
240
+ al = spPr.makeelement(qn('a:alpha'), {'val': str(int(a * 1000))})
241
+ srgb.append(al)
242
+ gs.append(srgb); gsLst.append(gs)
243
+ grad.append(gsLst)
244
+ lin = spPr.makeelement(qn('a:lin'), {'ang': str(int(angle * 60000)), 'scaled': '1'})
245
+ grad.append(lin)
246
+ if solid is not None:
247
+ solid.addprevious(grad)
248
+ spPr.remove(solid)
249
+ else:
250
+ spPr.append(grad)
251
+ return shp
252
+
253
+ def _add_image_fade(slide, name, x, y, w, h, bg='FFFFFF', direction='top'):
254
+ """图片:固定宽度、等比缩放(不裁剪、不拉伸、不加渐变),自然清晰。"""
255
+ path = os.path.join(IMG_DIR, name + '.jpg')
256
+ if not os.path.exists(path):
257
+ return
258
+ from PIL import Image
259
+ im = Image.open(path); iw, ih = im.size
260
+ # 等比缩放并约束在 (w,h) 框内(contain,不裁剪不变形,永不溢出边界)
261
+ ratio = min(w / iw, h / ih)
262
+ pw = Emu(int(iw * ratio)); ph = Emu(int(ih * ratio))
263
+ px = Emu(int(x + (w - pw) / 2)); py = Emu(int(y + (h - ph) / 2))
264
+ slide.shapes.add_picture(path, px, py, width=pw, height=ph)
265
+
266
+ def _add_side_image(slide, name, x, y, w, h, caption=None, accent='gold'):
267
+ """右侧配图:圆角 + 偏移强调框 + 底部图注条(有设计感,不生硬)。"""
268
+ path = os.path.join(IMG_DIR, name + '.jpg')
269
+ if not os.path.exists(path):
270
+ return
271
+ from PIL import Image, ImageDraw
272
+ im = Image.open(path).convert('RGB')
273
+ iw, ih = im.size
274
+ box_ratio = w / h; img_ratio = iw / ih
275
+ if img_ratio > box_ratio:
276
+ cw = int(ih * box_ratio); left = (iw - cw) // 2; im = im.crop((left, 0, left + cw, ih))
277
+ else:
278
+ ch = int(iw / box_ratio); top = (ih - ch) // 2; im = im.crop((0, top, iw, top + ch))
279
+ # 圆角(透明角)
280
+ rad = max(6, int(min(im.size) * 0.04))
281
+ mask = Image.new('L', im.size, 0)
282
+ ImageDraw.Draw(mask).rounded_rectangle([0, 0, im.size[0], im.size[1]], radius=rad, fill=255)
283
+ out = Image.new('RGBA', im.size, (0, 0, 0, 0))
284
+ out.paste(im, (0, 0), mask)
285
+ tmp = os.path.join('/tmp', f'ppt_rnd_{name}_{int(x/914400)}.png')
286
+ out.save(tmp, 'PNG')
287
+ # 偏移强调框(右下偏移金色衬底,形成层叠“相框”感)
288
+ _rect(slide, x + Inches(0.15), y + Inches(0.15), w, h, fill=AMBER)
289
+ slide.shapes.add_picture(tmp, x, y, width=w, height=h)
290
+ # 底部图注条(圆角 + 白字)
291
+ if caption:
292
+ cy = y + h - Inches(0.52)
293
+ _round_rect(slide, x + Inches(0.12), cy, w - Inches(0.24), Inches(0.4), fill=TEAL_D, radius=0.12)
294
+ tb, tf = _tb(slide, x + Inches(0.28), cy + Inches(0.03), w - Inches(0.55), Inches(0.34))
295
+ _para(tf, caption, size=11, bold=True, color=WHITE, first=True, space_after=0)
296
+
297
+ def _add_bottom_image(slide, name, x, y, w, h, caption=None, accent='gold', sub=None):
298
+ """底部横幅:左侧圆角照片 + 右侧藏蓝图注面板(图注+要点,不覆盖照片主体)。"""
299
+ path = os.path.join(IMG_DIR, name + '.jpg')
300
+ if not os.path.exists(path):
301
+ return
302
+ from PIL import Image, ImageDraw
303
+ im = Image.open(path).convert('RGB')
304
+ iw, ih = im.size
305
+ photo_w = w * 0.58
306
+ box_ratio = photo_w / h; img_ratio = iw / ih
307
+ if img_ratio > box_ratio:
308
+ cw = int(ih * box_ratio); left = (iw - cw) // 2; im = im.crop((left, 0, left + cw, ih))
309
+ else:
310
+ ch = int(iw / box_ratio); top = (ih - ch) // 2; im = im.crop((0, top, iw, top + ch))
311
+ rad = max(6, int(min(im.size) * 0.04))
312
+ mask = Image.new('L', im.size, 0)
313
+ ImageDraw.Draw(mask).rounded_rectangle([0, 0, im.size[0], im.size[1]], radius=rad, fill=255)
314
+ out = Image.new('RGBA', im.size, (0, 0, 0, 0))
315
+ out.paste(im, (0, 0), mask)
316
+ tmp = os.path.join('/tmp', f'ppt_rnd_{name}_{int(x/914400)}_{int(y/914400)}.png')
317
+ out.save(tmp, 'PNG')
318
+ slide.shapes.add_picture(tmp, x, y, width=photo_w, height=h)
319
+ # 右侧藏蓝图注面板(金色/藏蓝竖条 + 白字图注,垂直居中)
320
+ px = x + photo_w + Inches(0.22)
321
+ pw = w - photo_w - Inches(0.22)
322
+ _round_rect(slide, px, y, pw, h, fill=TEAL_D, radius=0.08)
323
+ accent_c = AMBER if accent == 'gold' else TEAL
324
+ _rect(slide, px, y, Inches(0.09), h, fill=accent_c)
325
+ if caption:
326
+ tb, tf = _tb(slide, px + Inches(0.34), y, pw - Inches(0.6), h)
327
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
328
+ _para(tf, caption, size=14, bold=True, color=WHITE, first=True, space_after=2, line_spacing=1.1)
329
+ if sub:
330
+ _para(tf, sub, size=11, color=RGBColor(0xBE, 0xCE, 0xE4), space_after=0, line_spacing=1.15)
331
+
332
+ def _add_cover_bleed(slide, name, bg='FFFFFF'):
333
+ """整页配图 + 多段渐变蒙层(左半可读、右半见图、平滑过渡)。"""
334
+ path = os.path.join(IMG_DIR, name + '.jpg')
335
+ if not os.path.exists(path):
336
+ return
337
+ from PIL import Image
338
+ im = Image.open(path); iw, ih = im.size
339
+ ratio = max(SLIDE_W / iw, SLIDE_H / ih)
340
+ pw = Emu(int(iw * ratio)); ph = Emu(int(ih * ratio))
341
+ px = Emu(int((SLIDE_W - pw) / 2)); py = Emu(int((SLIDE_H - ph) / 2))
342
+ slide.shapes.add_picture(path, px, py, width=pw, height=ph)
343
+ # 多段渐变蒙层:左 42% 不透明 → 65% 处全透明
344
+ shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SLIDE_W, SLIDE_H)
345
+ shp.line.fill.background(); shp.shadow.inherit = False
346
+ spPr = shp._element.spPr
347
+ solid = spPr.find(qn('a:solidFill'))
348
+ grad = spPr.makeelement(qn('a:gradFill'), {})
349
+ gsLst = spPr.makeelement(qn('a:gsLst'), {})
350
+ for pos, a in ((0, 100), (42000, 100), (68000, 0), (100000, 0)):
351
+ gs = spPr.makeelement(qn('a:gs'), {'pos': str(pos)})
352
+ srgb = spPr.makeelement(qn('a:srgbClr'), {'val': bg})
353
+ if a < 100:
354
+ al = spPr.makeelement(qn('a:alpha'), {'val': str(int(a * 1000))})
355
+ srgb.append(al)
356
+ gs.append(srgb); gsLst.append(gs)
357
+ grad.append(gsLst)
358
+ lin = spPr.makeelement(qn('a:lin'), {'ang': '0', 'scaled': '1'})
359
+ grad.append(lin)
360
+ if solid is not None:
361
+ solid.addprevious(grad); spPr.remove(solid)
362
+ else:
363
+ spPr.append(grad)
364
+
365
+ # ---------- 版式 ----------
366
+ def build_cover(prs, meta):
367
+ s = _new_slide(prs)
368
+ # 顶部与底部藏蓝细条(原版纯文字封面)
369
+ _rect(s, 0, 0, SLIDE_W, Inches(0.14), fill=TEAL)
370
+ _rect(s, 0, Inches(7.36), SLIDE_W, Inches(0.14), fill=TEAL)
371
+ # 左侧金色竖条装饰
372
+ _rect(s, Inches(0.9), Inches(1.5), Inches(0.14), Inches(3.6), fill=AMBER)
373
+ # 主标题(全宽,避开左上 logo,33pt 两行)
374
+ tb, tf = _tb(s, Inches(1.35), Inches(1.5), Inches(11.3), Inches(2.0))
375
+ for li, line in enumerate(meta['title'].split('\n')):
376
+ _para(tf, line, font=TITLE_FONT, size=33, bold=True, color=TEAL_D,
377
+ first=(li == 0), space_after=4, line_spacing=1.15)
378
+ # 副标题
379
+ tb, tf = _tb(s, Inches(1.35), Inches(3.6), Inches(11.0), Inches(0.6))
380
+ _para(tf, meta['subtitle'], size=20, bold=False, color=DARK, first=True, space_after=0)
381
+ # 金色分隔线
382
+ _rect(s, Inches(1.35), Inches(4.45), Inches(2.2), Pt(2.5), fill=AMBER)
383
+ # 来源
384
+ y = Inches(4.7)
385
+ for line in meta.get('source', []):
386
+ tb, tf = _tb(s, Inches(1.35), y, Inches(11.0), Inches(0.4))
387
+ _para(tf, line, size=13, color=DARK, first=True, space_after=0)
388
+ y += Inches(0.4)
389
+ # 占位
390
+ tb, tf = _tb(s, Inches(1.35), Inches(6.4), Inches(11.0), Inches(0.4))
391
+ _para(tf, meta.get('placeholder', '汇报人:___ 日期:___'), size=13, color=DARK, first=True, space_after=0)
392
+ # 右侧几何点缀(国企三色菱形阵列,平衡左侧文字,保持无图纯文字风格)
393
+ _dots = [
394
+ (11.9, 1.7, AMBER, 0.2), (11.35, 2.45, TEAL, 0.15), (11.85, 3.2, RED, 0.18),
395
+ (11.25, 3.95, AMBER, 0.13), (11.7, 4.7, TEAL, 0.2), (11.1, 5.45, RED, 0.15),
396
+ (11.6, 6.15, AMBER, 0.18),
397
+ ]
398
+ for dx, dy, dc, ds in _dots:
399
+ _shape(s, MSO_SHAPE.DIAMOND, Inches(dx), Inches(dy), Inches(ds), Inches(ds), fill=dc)
400
+ return s
401
+
402
+ def build_toc(prs, slide_spec):
403
+ s = _new_slide(prs)
404
+ _header(s, slide_spec.get('title', '目录'), page=slide_spec.get('page', 2))
405
+ items = slide_spec.get('items', [])
406
+ pages = slide_spec.get('pages', [])
407
+ # 两栏,纵向舒展
408
+ col_w = Inches(5.9)
409
+ for i, it in enumerate(items):
410
+ col = i % 2
411
+ row = i // 2
412
+ x = Inches(0.6) + col * Inches(6.3)
413
+ y = Inches(2.35) + row * Inches(1.32)
414
+ # 序号方块
415
+ box = _rect(s, x, y, Inches(0.55), Inches(0.55), fill=TEAL)
416
+ btf = box.text_frame; btf.word_wrap = False
417
+ bp = btf.paragraphs[0]; bp.alignment = PP_ALIGN.CENTER
418
+ br = bp.add_run(); _set_run(br, f"{i+1:02d}", size=17, bold=True, color=WHITE)
419
+ # 标题 + 右侧页码
420
+ tb, tf = _tb(s, x + Inches(0.75), y + Inches(0.02), col_w - Inches(1.6), Inches(0.55))
421
+ _para(tf, it, size=18, bold=True, color=TEAL_D, first=True, space_after=0)
422
+ if i < len(pages):
423
+ tb2, tf2 = _tb(s, x + col_w - Inches(1.15), y + Inches(0.06), Inches(0.95), Inches(0.4))
424
+ _para(tf2, str(pages[i]), size=15, bold=True, color=AMBER, align=PP_ALIGN.RIGHT, first=True, space_after=0)
425
+ # 底部说明(点明模块数、用途与时长)
426
+ _rect(s, Inches(0.6), Inches(6.25), Inches(12.13), Pt(1), fill=RGBColor(0xD8, 0xDE, 0xE4))
427
+ tb, tf = _tb(s, Inches(0.6), Inches(6.42), Inches(12.13), Inches(0.35))
428
+ _para(tf, "6 大模块 · 本场约 15~20 分钟", size=12.5, color=GREY, align=PP_ALIGN.CENTER, first=True, space_after=0)
429
+ return s
430
+
431
+ def build_two_col(prs, s):
432
+ slide = _new_slide(prs)
433
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
434
+ cols = s.get('cols', [])
435
+ n = len(cols)
436
+ if n == 0:
437
+ return
438
+ # 左文右图:有配图时,右侧整高配图(圆角+偏移框+图注)+ 左侧单栏上下堆叠内容
439
+ if s.get('image'):
440
+ _add_side_image(slide, s['image'], Inches(8.45), Inches(2.1), Inches(4.35), Inches(4.7),
441
+ caption=s.get('caption'), accent=s.get('accent', 'gold'))
442
+ content_w = Inches(7.55)
443
+ x0 = Inches(0.5)
444
+ y = Inches(2.15)
445
+ for ci, col in enumerate(cols):
446
+ heading = col.get('heading', '')
447
+ points = col.get('points', [])
448
+ _rect(slide, x0, y, Inches(0.08), Inches(0.3), fill=AMBER)
449
+ tb, tf = _tb(slide, x0 + Inches(0.22), y, content_w - Inches(0.22), Inches(0.4))
450
+ _para(tf, heading, size=17, bold=True, color=TEAL_D, first=True, space_after=0)
451
+ py = y + Inches(0.46)
452
+ for pi, pt in enumerate(points):
453
+ _bullet(slide, x0 + Inches(0.22), py, content_w - Inches(0.22), pt, pi, size=13)
454
+ py += Inches(0.46)
455
+ y = py + Inches(0.14)
456
+ return slide
457
+ # 无配图:左右两栏
458
+ col_w = Inches((12.33 - 0.3 * (n - 1)) / n)
459
+ x0 = Inches(0.5)
460
+ y0 = Inches(2.2)
461
+ for ci, col in enumerate(cols):
462
+ x = x0 + ci * (col_w + Inches(0.3))
463
+ heading = col.get('heading', '')
464
+ points = col.get('points', [])
465
+ _rect(slide, x, y0, Inches(0.08), Inches(0.34), fill=AMBER)
466
+ tb, tf = _tb(slide, x + Inches(0.22), y0, col_w - Inches(0.22), Inches(0.45))
467
+ _para(tf, heading, size=19, bold=True, color=TEAL_D, first=True, space_after=0)
468
+ py = y0 + Inches(0.62)
469
+ for pi, pt in enumerate(points):
470
+ _bullet(slide, x + Inches(0.22), py, col_w - Inches(0.22), pt, pi)
471
+ py += Inches(0.92)
472
+ return slide
473
+
474
+ def build_data_band(prs, s):
475
+ """左侧数据带 + 右侧要点(可选右图,三栏版式)。"""
476
+ slide = _new_slide(prs)
477
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
478
+ band = s.get('band', []) # 数据带:[(数字, 标签)]
479
+ right = s.get('right', {}) # {'heading','points'}
480
+ has_img = bool(s.get('image'))
481
+ if has_img:
482
+ _add_side_image(slide, s['image'], Inches(8.45), Inches(2.1), Inches(4.35), Inches(4.7),
483
+ caption=s.get('caption'), accent=s.get('accent', 'gold'))
484
+ band_x, band_w = Inches(0.5), Inches(3.0)
485
+ rx, rw = Inches(3.85), Inches(4.35)
486
+ else:
487
+ band_x, band_w = Inches(0.5), Inches(3.6)
488
+ rx, rw = Inches(4.5), Inches(8.2)
489
+ # 左侧藏蓝数据带
490
+ _rect(slide, band_x, Inches(2.2), band_w, Inches(4.5), fill=TEAL)
491
+ y = Inches(2.45)
492
+ for num, label in band:
493
+ tb, tf = _tb(slide, band_x + Inches(0.25), y, band_w - Inches(0.5), Inches(0.55))
494
+ _para(tf, num, size=21 if has_img else 26, bold=True, color=WHITE, first=True, space_after=0)
495
+ tb, tf = _tb(slide, band_x + Inches(0.25), y + Inches(0.44), band_w - Inches(0.5), Inches(0.9))
496
+ _para(tf, label, size=10.5 if has_img else 12, color=RGBColor(0xDD,0xEE,0xEC), first=True, space_after=0, line_spacing=1.1)
497
+ y += Inches(1.45)
498
+ # 右侧要点
499
+ heading = right.get('heading', '')
500
+ if heading:
501
+ _rect(slide, rx, Inches(2.25), Inches(0.08), Inches(0.34), fill=AMBER)
502
+ tb, tf = _tb(slide, rx + Inches(0.22), Inches(2.25), rw - Inches(0.22), Inches(0.45))
503
+ _para(tf, heading, size=17 if has_img else 19, bold=True, color=TEAL_D, first=True, space_after=0)
504
+ py = Inches(2.9)
505
+ for pi, pt in enumerate(right.get('points', [])):
506
+ _bullet(slide, rx + Inches(0.22), py, rw - Inches(0.22), pt, pi, size=12.5 if has_img else 16)
507
+ py += Inches(0.72)
508
+ # 数据来源脚注
509
+ if s.get('note'):
510
+ tb, tf = _tb(slide, rx, Inches(6.4), rw + Inches(0.2), Inches(0.3))
511
+ _para(tf, "注:" + s['note'], size=9.5 if has_img else 10.5, color=GREY, first=True, space_after=0)
512
+ return slide
513
+
514
+ def build_timeline(prs, s):
515
+ """横向时间线/流程 + 可选右侧配图。"""
516
+ slide = _new_slide(prs)
517
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
518
+ steps = s.get('steps', [])
519
+ n = len(steps)
520
+ if n == 0:
521
+ return
522
+ has_img = bool(s.get('image'))
523
+ if has_img:
524
+ _add_side_image(slide, s['image'], Inches(8.6), Inches(2.1), Inches(4.2), Inches(4.7),
525
+ caption=s.get('caption'), accent=s.get('accent', 'gold'))
526
+ lx, lw = Inches(0.7), Inches(7.6)
527
+ data_x0, data_w = Inches(0.5), Inches(7.9)
528
+ else:
529
+ lx, lw = Inches(0.7), Inches(11.9)
530
+ data_x0, data_w = Inches(0.5), Inches(12.33)
531
+ # 横向轴线
532
+ y_line = Inches(2.75)
533
+ _rect(slide, lx, y_line, lw, Pt(2.5), fill=LIGHT)
534
+ step_w = lw / n
535
+ for i, st in enumerate(steps):
536
+ cx = lx + step_w * i + step_w / 2
537
+ # 节点圆
538
+ _shape(slide, MSO_SHAPE.OVAL, cx - Inches(0.14), y_line - Inches(0.14) + Inches(0.05), Inches(0.28), Inches(0.28), fill=TEAL if i % 2 == 0 else AMBER)
539
+ # 时间文字
540
+ tb, tf = _tb(slide, cx - Inches(1.2), Inches(3.05), Inches(2.4), Inches(0.4))
541
+ _para(tf, st.get('time', ''), size=13 if has_img else 14, bold=True, color=TEAL_D, align=PP_ALIGN.CENTER, first=True, space_after=0)
542
+ # 说明
543
+ tb, tf = _tb(slide, cx - Inches(1.4), Inches(3.5), Inches(2.8), Inches(0.9))
544
+ _para(tf, st.get('text', ''), size=12 if has_img else 12.5, color=DARK, align=PP_ALIGN.CENTER, first=True, space_after=0, line_spacing=1.15)
545
+ # 底部补充(方法学依据)
546
+ if s.get('note'):
547
+ tb, tf = _tb(slide, lx, Inches(4.4), lw, Inches(0.5))
548
+ _para(tf, s['note'], size=11.5 if has_img else 13.5, color=GREY, first=True, space_after=0, line_spacing=1.25)
549
+ # 底部委员会数据栏
550
+ if s.get('data'):
551
+ y2 = Inches(5.35)
552
+ _rect(slide, data_x0, y2, data_w, Inches(0.06), fill=AMBER)
553
+ data = s['data']; dn = len(data)
554
+ for di, (val, label) in enumerate(data):
555
+ x = data_x0 + Inches(0.1) + di * (data_w / dn)
556
+ tb, tf = _tb(slide, x, y2 + Inches(0.2), data_w / dn - Inches(0.25), Inches(0.4))
557
+ _para(tf, val, size=15 if has_img else 18, bold=True, color=TEAL_D, first=True, space_after=0)
558
+ tb, tf = _tb(slide, x, y2 + Inches(0.56), data_w / dn - Inches(0.25), Inches(0.9))
559
+ _para(tf, label, size=10.5 if has_img else 11.5, color=GREY, first=True, space_after=0, line_spacing=1.1)
560
+ return slide
561
+
562
+ def build_funnel(prs, s):
563
+ """漏斗图 + 构成。"""
564
+ slide = _new_slide(prs)
565
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
566
+ levels = s.get('levels', []) # [('27 问题', 宽比例), ...]
567
+ n = len(levels)
568
+ if n == 0:
569
+ return
570
+ y = Inches(2.2)
571
+ h = Inches(0.62)
572
+ max_w = Inches(6.0)
573
+ for i, (label, ratio) in enumerate(levels):
574
+ w = max_w * ratio
575
+ x = Inches(0.7) + (max_w - w) / 2
576
+ _rect(slide, x, y, w, h, fill=TEAL if i % 2 == 0 else AMBER)
577
+ tbf = slide.shapes.add_textbox(x, y + Inches(0.12), w, Inches(0.4)).text_frame
578
+ tbf.word_wrap = False
579
+ p = tbf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
580
+ r = p.add_run(); _set_run(r, label, size=15, bold=True, color=WHITE)
581
+ y += Inches(0.8)
582
+ # 右侧构成
583
+ if s.get('composition'):
584
+ tb, tf = _tb(slide, Inches(7.4), Inches(2.2), Inches(5.4), Inches(0.4))
585
+ _para(tf, s['composition'].get('heading', '文献构成'), size=18, bold=True, color=TEAL_D, first=True, space_after=0)
586
+ py = Inches(2.8)
587
+ for pi, (label, val) in enumerate(s['composition'].get('items', [])):
588
+ _icon(slide, Inches(7.4), py + Inches(0.05), pi, 0.14)
589
+ tb, tf = _tb(slide, Inches(7.7), py, Inches(5.1), Inches(0.5))
590
+ _para(tf, f"{label}", size=15, color=DARK, first=True, space_after=0)
591
+ tb2, tf2 = _tb(slide, Inches(10.6), py, Inches(1.9), Inches(0.5))
592
+ _para(tf2, val, size=15, bold=True, color=AMBER, align=PP_ALIGN.RIGHT, first=True, space_after=0)
593
+ py += Inches(0.5)
594
+ # 底部切题插图(左照片 + 右图注面板)
595
+ if s.get('image'):
596
+ _add_bottom_image(slide, s['image'], Inches(0.5), Inches(5.05), Inches(12.33), Inches(1.8),
597
+ caption=s.get('caption'), accent=s.get('accent', 'gold'), sub=s.get('sub'))
598
+ return slide
599
+
600
+ def build_process(prs, s):
601
+ """N 步流程卡片 + 关键数据 + 可选底部横幅插图。"""
602
+ slide = _new_slide(prs)
603
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
604
+ steps = s.get('steps', [])
605
+ n = len(steps)
606
+ has_img = bool(s.get('image'))
607
+ card_w = Inches(11.9 / n - 0.25)
608
+ card_h = Inches(2.3) if has_img else Inches(2.6)
609
+ y = Inches(2.2)
610
+ for i, st in enumerate(steps):
611
+ x = Inches(0.5) + i * Inches(12.0 / n)
612
+ _round_rect(slide, x, y, card_w, card_h, fill=None, line=LIGHT, radius=0.06)
613
+ _rect(slide, x, y, card_w, Inches(0.16), fill=TEAL)
614
+ tb, tf = _tb(slide, x + Inches(0.16), y + Inches(0.28), card_w - Inches(0.32), Inches(0.4))
615
+ _para(tf, st.get('n', str(i+1)), size=18 if has_img else 20, bold=True, color=AMBER, first=True, space_after=0)
616
+ tb, tf = _tb(slide, x + Inches(0.16), y + Inches(0.68), card_w - Inches(0.32), Inches(0.5))
617
+ _para(tf, st.get('head', ''), size=14 if has_img else 15, bold=True, color=TEAL_D, first=True, space_after=0)
618
+ tb, tf = _tb(slide, x + Inches(0.16), y + Inches(1.22), card_w - Inches(0.32), Inches(1.0))
619
+ _para(tf, st.get('text', ''), size=11.5 if has_img else 12, color=DARK, first=True, space_after=0, line_spacing=1.12)
620
+ # 关键数据栏
621
+ if s.get('data'):
622
+ y2 = Inches(4.7) if has_img else Inches(5.0)
623
+ _rect(slide, Inches(0.5), y2, Inches(12.33), Inches(0.06), fill=AMBER)
624
+ for di, (val, label) in enumerate(s['data']):
625
+ x = Inches(0.6) + di * Inches(3.1)
626
+ tb, tf = _tb(slide, x, y2 + Inches(0.18), Inches(2.9), Inches(0.4))
627
+ _para(tf, val, size=16 if has_img else 17, bold=True, color=RED if di == 0 else TEAL_D, first=True, space_after=0)
628
+ tb, tf = _tb(slide, x, y2 + Inches(0.55), Inches(2.9), Inches(0.9))
629
+ _para(tf, label, size=11 if has_img else 11.5, color=GREY, first=True, space_after=0, line_spacing=1.1)
630
+ # 底部横幅插图
631
+ if has_img:
632
+ _add_bottom_image(slide, s['image'], Inches(0.5), Inches(5.72), Inches(12.33), Inches(1.2),
633
+ caption=s.get('caption'), accent=s.get('accent', 'gold'), sub=s.get('sub'))
634
+ # 数据来源脚注
635
+ if s.get('note') and not has_img:
636
+ tb, tf = _tb(slide, Inches(0.5), Inches(6.35), Inches(12.3), Inches(0.3))
637
+ _para(tf, "注:" + s['note'], size=10.5, color=GREY, first=True, space_after=0)
638
+ return slide
639
+
640
+ def build_matrix(prs, s):
641
+ """主题色块矩阵/条形分布 + 可选右侧配图。"""
642
+ slide = _new_slide(prs)
643
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
644
+ intro = s.get('intro', '')
645
+ has_img = bool(s.get('image'))
646
+ if has_img:
647
+ _add_side_image(slide, s['image'], Inches(8.45), Inches(2.1), Inches(4.35), Inches(4.7),
648
+ caption=s.get('caption'), accent=s.get('accent', 'gold'))
649
+ area_w = Inches(7.55); x0 = Inches(0.5)
650
+ else:
651
+ area_w = Inches(12.33); x0 = Inches(0.5)
652
+ y = Inches(2.05)
653
+ if intro:
654
+ tb, tf = _tb(slide, x0, y, area_w, Inches(0.5))
655
+ _para(tf, intro, size=14 if has_img else 15, color=GREY, first=True, space_after=0)
656
+ y = Inches(2.6) if has_img else Inches(2.95)
657
+ cells = s.get('cells', []) # [(主题, 条数)]
658
+ if has_img:
659
+ cell_w = (area_w - Inches(0.2)) / 3
660
+ row_h = Inches(1.18); cell_h = Inches(1.04)
661
+ gap = Inches(0.1)
662
+ else:
663
+ cell_w = Inches(3.9); row_h = Inches(1.32); cell_h = Inches(1.15)
664
+ gap = Inches(0.2)
665
+ # 3列网格
666
+ for i, (label, val) in enumerate(cells):
667
+ col = i % 3
668
+ row = i // 3
669
+ x = x0 + col * (cell_w + gap)
670
+ yy = y + row * row_h
671
+ _round_rect(slide, x, yy, cell_w, cell_h, fill=TEAL_L if (i % 2 == 0) else LIGHT, radius=0.08)
672
+ _rect(slide, x, yy, Inches(0.09), cell_h, fill=TEAL if val and int(val) > 1 else AMBER)
673
+ tb, tf = _tb(slide, x + Inches(0.2), yy + Inches(0.07), cell_w - Inches(0.35), Inches(0.45))
674
+ _para(tf, label, size=14 if has_img else 15, bold=True, color=TEAL_D, first=True, space_after=0)
675
+ tb, tf = _tb(slide, x + Inches(0.2), yy + Inches(0.5), cell_w - Inches(0.35), Inches(0.45))
676
+ _para(tf, f"{val} 条", size=18 if has_img else 20, bold=True, color=AMBER, first=True, space_after=0)
677
+ return slide
678
+
679
+ def build_table(prs, s):
680
+ slide = _new_slide(prs)
681
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
682
+ headers = s.get('headers', [])
683
+ rows = s.get('rows', [])
684
+ nr = len(rows) + 1
685
+ nc = len(headers)
686
+ if nc == 0:
687
+ return
688
+ gt = slide.shapes.add_table(nr, nc, Inches(0.5), Inches(2.2), Inches(12.33), Inches(0.55))
689
+ table = gt.table
690
+ for c, h in enumerate(headers):
691
+ cell = table.cell(0, c)
692
+ cell.text = h
693
+ cell.fill.solid(); cell.fill.fore_color.rgb = TEAL
694
+ for p in cell.text_frame.paragraphs:
695
+ p.alignment = PP_ALIGN.CENTER
696
+ for r in p.runs:
697
+ _set_run(r, h, size=13, bold=True, color=WHITE)
698
+ for ri, row in enumerate(rows, 1):
699
+ for ci, val in enumerate(row):
700
+ cell = table.cell(ri, ci)
701
+ cell.text = str(val)
702
+ cell.fill.solid()
703
+ cell.fill.fore_color.rgb = LIGHT if ri % 2 == 0 else WHITE
704
+ for p in cell.text_frame.paragraphs:
705
+ p.alignment = PP_ALIGN.CENTER if ci > 0 else PP_ALIGN.LEFT
706
+ for r in p.runs:
707
+ _set_run(r, str(val), size=12, color=DARK)
708
+ # 表注
709
+ if s.get('note'):
710
+ tb, tf = _tb(slide, Inches(0.5), Inches(6.3), Inches(12.3), Inches(0.5))
711
+ _para(tf, s['note'], size=12, color=GREY, first=True, space_after=0)
712
+ return slide
713
+
714
+ def build_cards(prs, s):
715
+ """策略卡片(两栏或三栏)+ 可选底部横幅插图。"""
716
+ slide = _new_slide(prs)
717
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
718
+ cards = s.get('cards', [])
719
+ n = len(cards)
720
+ if n == 0:
721
+ return
722
+ has_img = bool(s.get('image'))
723
+ card_h = Inches(2.7) if has_img else Inches(3.7)
724
+ card_w = Inches(12.33 / n - 0.25)
725
+ y = Inches(2.2)
726
+ for i, c in enumerate(cards):
727
+ x = Inches(0.5) + i * Inches(12.4 / n)
728
+ _round_rect(slide, x, y, card_w, card_h, fill=WHITE, line=LIGHT, radius=0.06)
729
+ _rect(slide, x, y, card_w, Inches(0.14), fill=TEAL)
730
+ tb, tf = _tb(slide, x + Inches(0.2), y + Inches(0.28), card_w - Inches(0.4), Inches(0.5))
731
+ _para(tf, c.get('head', ''), size=16 if has_img else 17, bold=True, color=TEAL_D, first=True, space_after=0)
732
+ py = y + Inches(0.9)
733
+ for pi, pt in enumerate(c.get('points', [])):
734
+ _bullet(slide, x + Inches(0.2), py, card_w - Inches(0.4), pt, pi, size=13 if has_img else 14.5)
735
+ py += Inches(0.56)
736
+ # 底部横幅插图(左照片 + 右图注面板)
737
+ if has_img:
738
+ _add_bottom_image(slide, s['image'], Inches(0.5), Inches(5.05), Inches(12.33), Inches(1.8),
739
+ caption=s.get('caption'), accent=s.get('accent', 'gold'), sub=s.get('sub'))
740
+ return slide
741
+
742
+ def build_checklist(prs, s):
743
+ """清单 + 沟通要点 + 可选底部横幅插图。"""
744
+ slide = _new_slide(prs)
745
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
746
+ left = s.get('left', {})
747
+ right = s.get('right', {})
748
+ has_img = bool(s.get('image'))
749
+ box_h = Inches(3.2) if has_img else Inches(4.6)
750
+ # 左:清单
751
+ _round_rect(slide, Inches(0.5), Inches(2.2), Inches(6.0), box_h, fill=TEAL_L, radius=0.05)
752
+ tb, tf = _tb(slide, Inches(0.75), Inches(2.32), Inches(5.5), Inches(0.45))
753
+ _para(tf, left.get('heading', ''), size=16 if has_img else 17, bold=True, color=TEAL_D, first=True, space_after=0)
754
+ py = Inches(2.9)
755
+ for pi, pt in enumerate(left.get('points', [])):
756
+ _icon(slide, Inches(0.75), py + Inches(0.04), pi, 0.13)
757
+ tb, tf = _tb(slide, Inches(1.05), py, Inches(5.2), Inches(0.55))
758
+ _para(tf, pt, size=13 if has_img else 13.5, color=DARK, first=True, space_after=0)
759
+ py += Inches(0.74 if has_img else 0.82)
760
+ # 右:沟通要点
761
+ _round_rect(slide, Inches(6.8), Inches(2.2), Inches(6.03), box_h, fill=LIGHT, radius=0.05)
762
+ tb, tf = _tb(slide, Inches(7.05), Inches(2.32), Inches(5.5), Inches(0.45))
763
+ _para(tf, right.get('heading', ''), size=16 if has_img else 17, bold=True, color=TEAL_D, first=True, space_after=0)
764
+ py = Inches(2.9)
765
+ for pi, pt in enumerate(right.get('points', [])):
766
+ _bullet(slide, Inches(7.05), py, Inches(5.4), pt, pi, size=13.5 if has_img else 14)
767
+ py += Inches(0.74 if has_img else 0.82)
768
+ # 底部横幅插图
769
+ if has_img:
770
+ _add_bottom_image(slide, s['image'], Inches(0.5), Inches(5.6), Inches(12.33), Inches(1.3),
771
+ caption=s.get('caption'), accent=s.get('accent', 'gold'), sub=s.get('sub'))
772
+ return slide
773
+
774
+ def build_summary(prs, s):
775
+ """三句话 + 医生行动清单(2×2 网格),可选右侧配图。"""
776
+ slide = _new_slide(prs)
777
+ _header(slide, s.get('title'), s.get('chapter'), s.get('page'))
778
+ has_img = bool(s.get('image'))
779
+ if has_img:
780
+ _add_side_image(slide, s['image'], Inches(8.45), Inches(2.1), Inches(4.35), Inches(4.7),
781
+ caption=s.get('caption'), accent=s.get('accent', 'gold'))
782
+ cw = Inches(7.55); x0 = Inches(0.5)
783
+ else:
784
+ cw = Inches(12.33); x0 = Inches(0.5)
785
+ # 三句话
786
+ y = Inches(2.3)
787
+ for i, sent in enumerate(s.get('sentences', [])):
788
+ _icon(slide, x0, y + Inches(0.06), i, 0.18, 'diamond')
789
+ tb, tf = _tb(slide, x0 + Inches(0.35), y, cw - Inches(0.35), Inches(0.55))
790
+ _para(tf, sent, size=15.5 if has_img else 17, bold=True, color=TEAL_D, first=True, space_after=0, line_spacing=1.1)
791
+ y += Inches(0.66 if has_img else 0.7)
792
+ # 分隔线 + 行动清单标题
793
+ _rect(slide, x0, y + Inches(0.03), cw, Inches(0.06), fill=AMBER)
794
+ tb, tf = _tb(slide, x0 + Inches(0.1), y + Inches(0.22), cw - Inches(0.2), Inches(0.4))
795
+ _para(tf, s.get('actions_title', '医生行动清单'), size=16.5 if has_img else 18, bold=True, color=TEAL_D, first=True, space_after=0)
796
+ # 2×2 行动卡片(统一浅蓝底,编号方块统一藏蓝)
797
+ ay = y + Inches(0.7)
798
+ actions = s.get('actions', [])
799
+ col_w = (cw - Inches(0.1) - Inches(0.3)) / 2
800
+ for pi, act in enumerate(actions):
801
+ col = pi % 2
802
+ row = pi // 2
803
+ x = x0 + col * (col_w + Inches(0.3))
804
+ yy = ay + row * Inches(0.82 if has_img else 0.88)
805
+ _round_rect(slide, x, yy, col_w, Inches(0.68 if has_img else 0.74), fill=TEAL_L, radius=0.14)
806
+ _rect(slide, x + Inches(0.15), yy + Inches(0.19), Inches(0.3), Inches(0.3), fill=TEAL)
807
+ tbb = slide.shapes.add_textbox(x + Inches(0.15), yy + Inches(0.2), Inches(0.3), Inches(0.3)).text_frame
808
+ tbb.word_wrap = False
809
+ pp = tbb.paragraphs[0]; pp.alignment = PP_ALIGN.CENTER
810
+ rr = pp.add_run(); _set_run(rr, str(pi+1), size=12 if has_img else 13, bold=True, color=WHITE)
811
+ tb, tf = _tb(slide, x + Inches(0.58), yy + Inches(0.1), col_w - Inches(0.72), Inches(0.5))
812
+ _para(tf, act, size=13.5 if has_img else 15, color=DARK, first=True, space_after=0, line_spacing=1.1)
813
+ return slide
814
+
815
+ def build_thanks(prs, s):
816
+ slide = _new_slide(prs)
817
+ # 原版纯文字结尾
818
+ _rect(slide, 0, 0, SLIDE_W, Inches(0.14), fill=TEAL)
819
+ _rect(slide, 0, Inches(7.36), SLIDE_W, Inches(0.14), fill=TEAL)
820
+ tb, tf = _tb(slide, Inches(0.5), Inches(2.7), Inches(12.3), Inches(1.0))
821
+ _para(tf, s.get('text', '感谢聆听'), font=TITLE_FONT, size=44, bold=True, color=TEAL_D, align=PP_ALIGN.CENTER, first=True, space_after=0)
822
+ if s.get('subtitle'):
823
+ tb, tf = _tb(slide, Inches(0.5), Inches(3.8), Inches(12.3), Inches(0.5))
824
+ _para(tf, s['subtitle'], size=20, color=DARK, align=PP_ALIGN.CENTER, first=True, space_after=0)
825
+ # 琥珀色强调分隔线
826
+ _rect(slide, Inches(6.17), Inches(4.45), Inches(1.0), Pt(2.5), fill=AMBER)
827
+ y = Inches(4.6)
828
+ for line in s.get('source', []):
829
+ tb, tf = _tb(slide, Inches(0.5), y, Inches(12.3), Inches(0.4))
830
+ _para(tf, line, size=12, color=DARK, align=PP_ALIGN.CENTER, first=True, space_after=0)
831
+ y += Inches(0.4)
832
+ return slide
833
+
834
+ BUILDERS = {
835
+ 'cover': build_cover, 'toc': build_toc, 'thanks': build_thanks,
836
+ 'two_col': build_two_col, 'data_band': build_data_band,
837
+ 'timeline': build_timeline, 'funnel': build_funnel, 'process': build_process,
838
+ 'matrix': build_matrix, 'table': build_table, 'cards': build_cards,
839
+ 'checklist': build_checklist, 'summary': build_summary,
840
+ }
841
+
842
+ def _default_template():
843
+ """默认模板:config.json 的 template → 内置 assets/base_template.pptx。"""
844
+ t = _resolve_path(_load_config().get('template', ''))
845
+ if t and os.path.exists(t):
846
+ return t
847
+ t = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'base_template.pptx')
848
+ return t if os.path.exists(t) else ''
849
+
850
+ TPL = _default_template()
851
+
852
+
853
+ def generate(spec, out_path, template=TPL):
854
+ # 以模板为基底,保留其浅蓝母版背景
855
+ prs = Presentation(template)
856
+ prs.slide_width = SLIDE_W
857
+ prs.slide_height = SLIDE_H
858
+ # 删除模板原始页(保留母版/主题/背景)
859
+ sldIdLst = prs.slides._sldIdLst
860
+ for sldId in list(sldIdLst):
861
+ rId = sldId.get(qn('r:id'))
862
+ if rId:
863
+ try:
864
+ prs.part.drop_rel(rId)
865
+ except Exception:
866
+ pass
867
+ sldIdLst.remove(sldId)
868
+ meta = spec.get('meta', {})
869
+ global FOOTER
870
+ FOOTER = meta.get('footer', '') or ''
871
+ slides = spec.get('slides', [])
872
+ for s in slides:
873
+ stype = s.get('type', 'two_col')
874
+ if stype == 'cover':
875
+ build_cover(prs, meta)
876
+ else:
877
+ BUILDERS.get(stype, build_two_col)(prs, s)
878
+ notes = s.get('notes') if stype != 'cover' else meta.get('notes')
879
+ if notes:
880
+ try:
881
+ prs.slides[-1].notes_slide.notes_text_frame.text = notes
882
+ except Exception:
883
+ pass
884
+ prs.save(out_path)
885
+ return out_path
886
+
887
+ if __name__ == '__main__':
888
+ if len(sys.argv) < 3:
889
+ print("用法: python3 generate_consensus.py spec.json out.pptx")
890
+ sys.exit(1)
891
+ spec = json.load(open(sys.argv[1], encoding='utf-8'))
892
+ print("已生成:", generate(spec, sys.argv[2]))