@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.2

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 +2288 -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,1356 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PPT 生成引擎 —— 以指定模板为基底,套用其视觉规范(藏蓝 #192D77 / 暗红 #C00000 /
4
+ 章节标 #D24726,标题华文中宋,正文 Noto Serif SC),按 JSON 内容规格生成可编辑 .pptx。
5
+
6
+ 版式原则:饱满充实、无过多空白、不杂乱 —— 内容卡片网格铺满正文区,底部统一页脚+页码,
7
+ 可选「要点提炼」条;标题/章节/页脚三区对齐一致。
8
+
9
+ 用法:
10
+ python3 generate_ppt.py spec.json 输出.pptx [--template 模板.pptx]
11
+
12
+ 内容规格 JSON 见 README 或同目录 spec 示例。
13
+ """
14
+ import sys, os, json, math
15
+ from pptx import Presentation
16
+ from pptx.util import Inches, Pt, Emu
17
+ from pptx.dml.color import RGBColor
18
+ from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
19
+ from pptx.enum.shapes import MSO_SHAPE
20
+ from pptx.oxml.ns import qn
21
+ from pptx.chart.data import CategoryChartData
22
+ from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
23
+ from pptx.opc.constants import RELATIONSHIP_TYPE as RT
24
+
25
+ # ------------------------- 配色系统 -------------------------
26
+ # 主色/强调/章节标/浅底 会按所选 palette 切换;BLACK/GREY 为固定中性色。
27
+ # 默认 original = 模板原配色。
28
+ NAVY = RGBColor(0x19, 0x2D, 0x77) # primary 主色:深藏蓝
29
+ RED = RGBColor(0xC0, 0x00, 0x00) # accent 强调:暗红
30
+ ORANGE = RGBColor(0xD2, 0x47, 0x26) # marker 章节标:红橙
31
+ BLACK = RGBColor(0x00, 0x00, 0x00)
32
+ GREY = RGBColor(0x59, 0x59, 0x59)
33
+ LIGHT = RGBColor(0xE7, 0xE6, 0xE6) # light 浅底(卡片/表格斑马纹)
34
+ BG = RGBColor(0xF4, 0xF6, 0xF9) # 页面底(覆盖模板母版背景图)
35
+
36
+ PALETTES = {
37
+ 'original': {
38
+ 'name': '原模板色', 'primary': '192D77', 'accent': 'C00000', 'marker': 'D24726',
39
+ 'light': 'E7E6E6', 'series': ['192D77', 'C00000', 'D24726', '44546A', '5B9BD5'],
40
+ },
41
+ 'coastal': { # 海岸蓝调
42
+ 'name': '海岸蓝调', 'primary': '1B4965', 'accent': 'E07A5F', 'marker': '62B6CB',
43
+ 'light': 'E3EEF6', 'series': ['1B4965', '62B6CB', '5FA8D3', 'E07A5F', '81B29A'],
44
+ },
45
+ 'ocean': { # 深海蓝
46
+ 'name': '深海蓝', 'primary': '1D3557', 'accent': 'E63946', 'marker': '457B9D',
47
+ 'light': 'F1FAEE', 'series': ['1D3557', '457B9D', 'A8DADC', 'E63946', 'F4A261'],
48
+ },
49
+ 'aurora': { # 极光青绿
50
+ 'name': '极光青绿', 'primary': '2A9D8F', 'accent': 'E76F51', 'marker': '48CAE4',
51
+ 'light': 'E8F4F2', 'series': ['2A9D8F', '48CAE4', '90E0EF', 'E76F51', 'F4A261'],
52
+ },
53
+ 'indigo': { # 靛蓝紫
54
+ 'name': '靛蓝紫', 'primary': '312E81', 'accent': 'E11D48', 'marker': '6366F1',
55
+ 'light': 'EEF2FF', 'series': ['312E81', '6366F1', '818CF8', 'E11D48', 'F59E0B'],
56
+ },
57
+ 'institutional': { # 体制内庄重:深藏蓝 + 中国红 + 金
58
+ 'name': '体制内庄重', 'primary': '1F3A5F', 'accent': 'C0392B', 'marker': 'C9A227',
59
+ 'light': 'EEF1F5', 'series': ['1F3A5F', 'C0392B', 'C9A227', '6B7A99', 'A8B4C8'],
60
+ },
61
+ 'rose': { # 暖玫瑰:酒红 + 玫瑰 + 杏橘 + 奶油(适合温暖情感主题)
62
+ 'name': '暖玫瑰', 'primary': '8C3B4C', 'accent': 'C2576B', 'marker': 'E08E6F',
63
+ 'light': 'F6ECE7', 'series': ['8C3B4C', 'C2576B', 'E08E6F', 'D9A08A', '9C7A6B'],
64
+ },
65
+ }
66
+
67
+ _ACTIVE_SERIES = [NAVY, ORANGE, RED, GREY]
68
+
69
+ def _apply_palette(name):
70
+ global NAVY, RED, ORANGE, LIGHT, _ACTIVE_SERIES
71
+ p = PALETTES.get(name, PALETTES['original'])
72
+ NAVY = RGBColor.from_string(p['primary'])
73
+ RED = RGBColor.from_string(p['accent'])
74
+ ORANGE = RGBColor.from_string(p['marker'])
75
+ LIGHT = RGBColor.from_string(p['light'])
76
+ _ACTIVE_SERIES = [RGBColor.from_string(s) for s in p['series']]
77
+ return p
78
+
79
+ TITLE_FONT = "华文中宋"
80
+ BODY_FONT = "Noto Serif SC"
81
+ YAHEI = "微软雅黑"
82
+
83
+ SLIDE_W = Inches(13.333)
84
+ SLIDE_H = Inches(7.5)
85
+
86
+ SIZE_CHAPTER = 18 # 章节标记
87
+ SIZE_TITLE = 30 # 页标题
88
+ SIZE_HEAD = 20 # 小节标题
89
+ SIZE_BODY = 16 # 正文
90
+ SIZE_SMALL = 13 # 注脚
91
+
92
+ # 正文版心(英寸)
93
+ MARGIN_X = 0.45
94
+ CONTENT_TOP = 2.0
95
+ CONTENT_BOTTOM = 6.9 # 无 takeaway 时的正文下界
96
+ CONTENT_BOTTOM_TAKEAWAY = 6.26 # 有 takeaway 时的正文下界
97
+ FOOTER_Y = 6.98
98
+
99
+ DECK_TITLE = ""
100
+
101
+
102
+ def _set_run(run, text, font=TITLE_FONT, size=16, bold=False, color=NAVY, italic=False):
103
+ run.text = text
104
+ run.font.name = font
105
+ run.font.size = Pt(size)
106
+ run.font.bold = bold
107
+ run.font.italic = italic
108
+ run.font.color.rgb = color
109
+ rPr = run._r.get_or_add_rPr()
110
+ ea = rPr.find(qn('a:ea'))
111
+ if ea is None:
112
+ ea = rPr.makeelement(qn('a:ea'), {})
113
+ rPr.append(ea)
114
+ ea.set('typeface', font)
115
+
116
+
117
+ def _add_textbox(slide, x, y, w, h, wrap=True):
118
+ tb = slide.shapes.add_textbox(x, y, w, h)
119
+ tf = tb.text_frame
120
+ tf.word_wrap = wrap
121
+ tf.margin_left = 0
122
+ tf.margin_right = 0
123
+ tf.margin_top = 0
124
+ tf.margin_bottom = 0
125
+ return tb, tf
126
+
127
+
128
+ def _add_para(tf, text, font=TITLE_FONT, size=16, bold=False, color=NAVY,
129
+ align=PP_ALIGN.LEFT, first=False, space_after=6, line_spacing=1.15):
130
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
131
+ p.alignment = align
132
+ p.space_after = Pt(space_after)
133
+ p.line_spacing = line_spacing
134
+ r = p.add_run()
135
+ _set_run(r, text, font=font, size=size, bold=bold, color=color)
136
+ return p
137
+
138
+
139
+ def _add_rect(slide, x, y, w, h, fill=None, line=None):
140
+ shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
141
+ if fill is None:
142
+ shp.fill.background()
143
+ else:
144
+ shp.fill.solid()
145
+ shp.fill.fore_color.rgb = fill
146
+ if line is None:
147
+ shp.line.fill.background()
148
+ else:
149
+ shp.line.color.rgb = line
150
+ shp.line.width = Pt(1)
151
+ shp.shadow.inherit = False
152
+ return shp
153
+
154
+
155
+ # ------------------------- 公共版式元素 -------------------------
156
+
157
+ def _add_background(slide):
158
+ """覆盖模板母版背景图,铺干净浅色底。"""
159
+ _add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, fill=BG)
160
+
161
+
162
+ _BG_TEXTURES = ['theme_rose', 'theme_peach', 'theme_blush', 'theme_apricot',
163
+ 'theme_mauve', 'theme_sunset', 'theme_coral', 'theme_glow']
164
+ _BASE_DIR = os.path.dirname(os.path.abspath(__file__))
165
+
166
+
167
+ def _add_rich_background(slide, page=None):
168
+ """满幅暖色纹理背景(渐变+爱心+光晕若隐若现)+ 角落装饰,填满空白、避免单调。"""
169
+ _add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, fill=BG)
170
+ if page is not None and _BG_TEXTURES:
171
+ tex = os.path.join(_BASE_DIR, 'assets', _BG_TEXTURES[(page - 1) % len(_BG_TEXTURES)] + '.jpg')
172
+ if os.path.exists(tex):
173
+ crop = _cover_crop(tex, SLIDE_W, SLIDE_H)
174
+ slide.shapes.add_picture(crop, 0, 0, SLIDE_W, SLIDE_H)
175
+ # 高亮度蒙层:让纹理淡入,文字仍清晰,同时消除“大白板”感
176
+ _add_gradient_rect(slide, 0, 0, SLIDE_W, SLIDE_H, 'FFFFFF', angle=0, a1=73, a2=73)
177
+ # 右下角柔和装饰圆 + 小爱心点(纯色淡影,填补角落)
178
+ for (cx, cy, r, col) in [(12.9, 7.0, 2.5, RGBColor(0xF3, 0xDE, 0xD2)),
179
+ (-0.7, -0.7, 2.0, RGBColor(0xF6, 0xE6, 0xDB))]:
180
+ c = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(cx) - Inches(r), Inches(cy) - Inches(r),
181
+ Inches(r * 2), Inches(r * 2))
182
+ c.fill.solid(); c.fill.fore_color.rgb = col; c.line.fill.background(); c.shadow.inherit = False
183
+
184
+
185
+ def _add_topbar(slide):
186
+ """顶部主色条 + 金色细线(体制内双色带)。"""
187
+ _add_rect(slide, 0, 0, SLIDE_W, Inches(0.12), fill=NAVY)
188
+ _add_rect(slide, 0, Inches(0.12), SLIDE_W, Inches(0.035), fill=ORANGE)
189
+
190
+
191
+ def _add_chapter_marker(slide, chapter):
192
+ """章节标记(红橙小字)+ 右上角小菱形与短线点缀。"""
193
+ if chapter:
194
+ tb, tf = _add_textbox(slide, Inches(MARGIN_X), Inches(0.55), Inches(11.0), Inches(0.35))
195
+ _add_para(tf, chapter, font=BODY_FONT, size=SIZE_CHAPTER, bold=False,
196
+ color=ORANGE, first=True, space_after=0)
197
+ d = slide.shapes.add_shape(MSO_SHAPE.DIAMOND, Inches(12.28), Inches(0.6), Inches(0.12), Inches(0.12))
198
+ d.fill.solid(); d.fill.fore_color.rgb = ORANGE; d.line.fill.background(); d.shadow.inherit = False
199
+ _add_rect(slide, Inches(12.46), Inches(0.645), Inches(0.42), Inches(0.03), fill=ORANGE)
200
+
201
+
202
+ def _add_title(slide, title, size=SIZE_TITLE, y=Inches(1.22)):
203
+ """标题 + 左侧强调竖条 + 底部短线。"""
204
+ _add_rect(slide, Inches(MARGIN_X), y + Inches(0.03), Inches(0.09), Inches(0.58), fill=RED)
205
+ tb, tf = _add_textbox(slide, Inches(0.72), y, Inches(11.9), Inches(0.7))
206
+ _add_para(tf, title, font=TITLE_FONT, size=size, bold=True, color=NAVY,
207
+ first=True, space_after=0)
208
+ _add_rect(slide, Inches(0.72), y + Inches(0.7), Inches(1.4), Pt(3), fill=ORANGE)
209
+
210
+
211
+ def _add_footer(slide, page):
212
+ """底部页脚:细线 + 金色菱形点缀 + 短标题 + 页码。"""
213
+ _add_rect(slide, Inches(MARGIN_X), Inches(FOOTER_Y), Inches(12.43), Pt(1), fill=LIGHT)
214
+ d = slide.shapes.add_shape(MSO_SHAPE.DIAMOND, Inches(MARGIN_X), Inches(7.045), Inches(0.1), Inches(0.1))
215
+ d.fill.solid(); d.fill.fore_color.rgb = ORANGE; d.line.fill.background(); d.shadow.inherit = False
216
+ if DECK_TITLE:
217
+ tb, tf = _add_textbox(slide, Inches(0.62), Inches(7.02), Inches(10.5), Inches(0.34))
218
+ _add_para(tf, DECK_TITLE, font=YAHEI, size=11.5, bold=True, color=RGBColor(0x9A, 0x5A, 0x6A),
219
+ first=True, space_after=0)
220
+ if page is not None:
221
+ tb2, tf2 = _add_textbox(slide, Inches(12.2), Inches(7.02), Inches(0.7), Inches(0.34))
222
+ _add_para(tf2, str(page), font=YAHEI, size=11.5, bold=True, color=RGBColor(0x9A, 0x5A, 0x6A),
223
+ align=PP_ALIGN.RIGHT, first=True, space_after=0)
224
+
225
+
226
+ def _add_takeaway(slide, text):
227
+ """底部「结语」条:深玫瑰底 + 白字 + 金色爱心点缀,视觉重量足、不单薄。"""
228
+ if not text:
229
+ return
230
+ y = Inches(6.24)
231
+ _add_rect(slide, Inches(MARGIN_X), y, Inches(12.43), Inches(0.56), fill=NAVY)
232
+ _add_rect(slide, Inches(MARGIN_X), y, Inches(0.09), Inches(0.56), fill=ORANGE)
233
+ # 金色菱形点缀(代替爱心)
234
+ d = slide.shapes.add_shape(MSO_SHAPE.DIAMOND, Inches(0.72), y + Inches(0.2), Inches(0.16), Inches(0.16))
235
+ d.fill.solid(); d.fill.fore_color.rgb = ORANGE; d.line.fill.background(); d.shadow.inherit = False
236
+ tb, tf = _add_textbox(slide, Inches(1.02), y + Inches(0.05), Inches(11.5), Inches(0.48))
237
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
238
+ _add_para(tf, "「" + text + "」", font=BODY_FONT, size=14, bold=True,
239
+ color=RGBColor(0xFF, 0xFF, 0xFF), first=True, space_after=0, line_spacing=1.05)
240
+
241
+
242
+ def _add_bullet_icon(slide, x, y, size=0.12):
243
+ """要点前几何小圆点(统一主色,避免杂色)。"""
244
+ ic = slide.shapes.add_shape(MSO_SHAPE.OVAL, x, y, Inches(size), Inches(size))
245
+ ic.fill.solid(); ic.fill.fore_color.rgb = NAVY; ic.line.fill.background()
246
+ ic.shadow.inherit = False
247
+ return ic
248
+
249
+
250
+ def _card(slide, x, y, w, h, accent=NAVY):
251
+ """内容卡片:白底 + 细边 + 顶部强调条(层次更清晰)。"""
252
+ _add_rect(slide, x, y, w, h, fill=RGBColor(0xFF, 0xFF, 0xFF), line=RGBColor(0xD8, 0xDE, 0xE8))
253
+ _add_rect(slide, x, y, w, Inches(0.06), fill=accent)
254
+
255
+
256
+ # ------------------------- 图片「花活」工具 -------------------------
257
+
258
+ def _cover_crop(src, tw, th):
259
+ """把图片中心裁剪到 tw×th 比例,返回临时文件路径(避免拉伸变形)。"""
260
+ from PIL import Image
261
+ import tempfile
262
+ im = Image.open(src)
263
+ w, h = im.size
264
+ target = tw / th
265
+ cur = w / h
266
+ if cur > target: # 太宽,裁左右
267
+ new_w = int(h * target)
268
+ left = (w - new_w) // 2
269
+ im = im.crop((left, 0, left + new_w, h))
270
+ elif cur < target: # 太高,裁上下
271
+ new_h = int(w / target)
272
+ top = (h - new_h) // 2
273
+ im = im.crop((0, top, w, top + new_h))
274
+ tmp = tempfile.NamedTemporaryFile(suffix='.jpg', delete=False)
275
+ im.convert('RGB').save(tmp.name, 'JPEG', quality=90)
276
+ return tmp.name
277
+
278
+
279
+ def _add_gradient_rect(slide, x, y, w, h, rgb, angle=0, a1=100, a2=0):
280
+ """渐变矩形(同一颜色透明度渐变,用于图片融入背景/封面压暗)。angle 0=左→右, 90=上→下。"""
281
+ shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
282
+ shp.line.fill.background(); shp.shadow.inherit = False
283
+ spPr = shp._element.spPr
284
+ solid = spPr.find(qn('a:solidFill'))
285
+ grad = spPr.makeelement(qn('a:gradFill'), {})
286
+ gsLst = spPr.makeelement(qn('a:gsLst'), {})
287
+ for pos, a in ((0, a1), (100000, a2)):
288
+ gs = spPr.makeelement(qn('a:gs'), {'pos': str(pos)})
289
+ srgb = spPr.makeelement(qn('a:srgbClr'), {'val': rgb})
290
+ if a < 100:
291
+ al = spPr.makeelement(qn('a:alpha'), {'val': str(int(a * 1000))})
292
+ srgb.append(al)
293
+ gs.append(srgb); gsLst.append(gs)
294
+ grad.append(gsLst)
295
+ lin = spPr.makeelement(qn('a:lin'), {'ang': str(int(angle * 60000)), 'scaled': '1'})
296
+ grad.append(lin)
297
+ if solid is not None:
298
+ solid.addprevious(grad)
299
+ spPr.remove(solid)
300
+ else:
301
+ spPr.append(grad)
302
+ return shp
303
+
304
+
305
+ def _set_picture_fill(shape, rId):
306
+ """把 shape 的填充替换为图片填充(拉伸填满)。"""
307
+ spPr = shape._element.spPr
308
+ for tag in ('a:solidFill', 'a:noFill', 'a:blipFill', 'a:gradFill'):
309
+ f = spPr.find(qn(tag))
310
+ if f is not None:
311
+ spPr.remove(f)
312
+ bf = spPr.makeelement(qn('a:blipFill'), {})
313
+ blip = spPr.makeelement(qn('a:blip'), {qn('r:embed'): rId})
314
+ stretch = spPr.makeelement(qn('a:stretch'), {})
315
+ stretch.append(spPr.makeelement(qn('a:fillRect'), {}))
316
+ bf.append(blip); bf.append(stretch)
317
+ spPr.append(bf)
318
+
319
+
320
+ def _add_image_rounded(slide, img_path, x, y, w, h, radius=0.08):
321
+ """圆角图片:中心裁剪到目标比例后,用圆角矩形图片填充。"""
322
+ from PIL import Image
323
+ im = Image.open(img_path)
324
+ crop = _cover_crop(img_path, w, h)
325
+ pic = slide.shapes.add_picture(crop, x, y, w, h)
326
+ rId = pic._element.find(qn('p:blipFill')).find(qn('a:blip')).get(qn('r:embed'))
327
+ pic._element.getparent().remove(pic._element)
328
+ shp = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
329
+ try:
330
+ shp.adjustments[0] = radius
331
+ except Exception:
332
+ pass
333
+ shp.line.fill.background()
334
+ shp.shadow.inherit = False
335
+ _set_picture_fill(shp, rId)
336
+ return shp
337
+
338
+
339
+ def _add_image_circle(slide, img_path, x, y, d):
340
+ """圆形图片(头像式)。"""
341
+ crop = _cover_crop(img_path, d, d)
342
+ pic = slide.shapes.add_picture(crop, x, y, d, d)
343
+ rId = pic._element.find(qn('p:blipFill')).find(qn('a:blip')).get(qn('r:embed'))
344
+ pic._element.getparent().remove(pic._element)
345
+ shp = slide.shapes.add_shape(MSO_SHAPE.OVAL, x, y, d, d)
346
+ shp.line.fill.background()
347
+ shp.shadow.inherit = False
348
+ _set_picture_fill(shp, rId)
349
+ return shp
350
+
351
+
352
+ def _add_rounded_rect(slide, x, y, w, h, radius=0.08, fill=None, line=None, line_w=1.0):
353
+ """圆角矩形(可作投影底 / 描边框)。"""
354
+ shp = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
355
+ try:
356
+ shp.adjustments[0] = radius
357
+ except Exception:
358
+ pass
359
+ if fill is None:
360
+ shp.fill.background()
361
+ else:
362
+ shp.fill.solid(); shp.fill.fore_color.rgb = fill
363
+ if line is None:
364
+ shp.line.fill.background()
365
+ else:
366
+ shp.line.color.rgb = line; shp.line.width = Pt(line_w)
367
+ shp.shadow.inherit = False
368
+ return shp
369
+
370
+
371
+ def _add_image_framed(slide, img_path, x, y, w, h, radius=0.09, border=None):
372
+ """带柔和投影 + 细描边的圆角图片(更精致、更有质感)。"""
373
+ _add_rounded_rect(slide, x + Inches(0.04), y + Inches(0.05), w, h, radius,
374
+ fill=RGBColor(0xDF, 0xE4, 0xEC))
375
+ _add_image_rounded(slide, img_path, x, y, w, h, radius)
376
+ if border is not None:
377
+ _add_rounded_rect(slide, x, y, w, h, radius, fill=None, line=border, line_w=1.0)
378
+
379
+
380
+ def _add_fullbleed(slide, img_path):
381
+ """整页铺满的背景图(中心裁剪,不变形)。"""
382
+ crop = _cover_crop(img_path, SLIDE_W, SLIDE_H)
383
+ return slide.shapes.add_picture(crop, 0, 0, SLIDE_W, SLIDE_H)
384
+
385
+
386
+ def _add_caption_chip(slide, x, y, text):
387
+ """图片署名小徽章。"""
388
+ if not text:
389
+ return
390
+ tb, tf = _add_textbox(slide, x, y, Inches(4.0), Inches(0.3))
391
+ _add_para(tf, text, font=YAHEI, size=10, bold=False, color=RGBColor(0x8A, 0x93, 0xA6),
392
+ first=True, space_after=0)
393
+
394
+
395
+ def _resolve_image(d):
396
+ """从 dict 取本地 image 路径(或 keyword 联网取图),返回 (path, attribution)。"""
397
+ img = d.get('image', '')
398
+ if img:
399
+ if not os.path.isabs(img):
400
+ img = os.path.join(os.path.dirname(os.path.abspath(__file__)), img)
401
+ if os.path.exists(img):
402
+ return img, d.get('attr', '')
403
+ kw = d.get('keyword', '')
404
+ if kw:
405
+ import image_fetch
406
+ cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'output', 'img')
407
+ os.makedirs(cache_dir, exist_ok=True)
408
+ safe = "".join(c for c in kw if c.isalnum() or c in '-_') or 'img'
409
+ out = os.path.join(cache_dir, f"{safe}.jpg")
410
+ try:
411
+ return image_fetch.fetch(kw, out)
412
+ except Exception:
413
+ return '', ''
414
+ return '', ''
415
+
416
+
417
+ def _grid(n):
418
+ """按卡片数量选网格列数/行数,尽量铺满。"""
419
+ if n <= 1:
420
+ return 1, 1
421
+ if n == 2:
422
+ return 2, 1
423
+ if n == 3:
424
+ return 3, 1
425
+ if n == 4:
426
+ return 4, 1
427
+ if n <= 6:
428
+ return 3, 2
429
+ return 3, math.ceil(n / 3)
430
+
431
+
432
+ def _grid_cells(n, top, bottom, cols, rows, gap=Inches(0.25)):
433
+ """密集网格:铺满正文区,末行卡片自动加宽填满(不留空槽)。返回 [(x,y,w,h)]。"""
434
+ avail_w = Inches(12.43)
435
+ avail_h = bottom - top
436
+ cells = []
437
+ for i in range(n):
438
+ r = i // cols
439
+ items_in_row = min(cols, n - r * cols)
440
+ cw = (avail_w - gap * (items_in_row - 1)) / items_in_row
441
+ ch = (avail_h - gap * (rows - 1)) / rows
442
+ c = i - r * cols
443
+ x = Inches(MARGIN_X) + c * (cw + gap)
444
+ y = top + r * (ch + gap)
445
+ cells.append((x, y, cw, ch))
446
+ return cells
447
+
448
+
449
+ def _card_need_h(b):
450
+ """估算一张 section 卡片内容所需高度(英寸):图文卡按图+标题+要点,文字卡按标题+要点。"""
451
+ pts = len(b.get('points', [])) or 0
452
+ if b.get('image') or b.get('keyword'):
453
+ return 1.9 + pts * 0.55 + 0.25
454
+ return 0.72 + pts * 0.68 + 0.4
455
+
456
+
457
+ # ------------------------- 各版式构建器 -------------------------
458
+
459
+ def build_cover(prs, meta, page=None):
460
+ slide = prs.slides.add_slide(_blank_layout(prs))
461
+ _add_background(slide)
462
+ # 整页英雄图 + 左侧渐变压暗(图文并茂的封面「花活」)
463
+ hero, _ = _resolve_image(meta)
464
+ dark = bool(hero)
465
+ if dark:
466
+ _add_fullbleed(slide, hero)
467
+ # 全页轻蒙层,保证文字在暖色图上仍清晰(可用 cover_overlay 覆盖为暖深色)
468
+ ov = meta.get('cover_overlay', str(NAVY))
469
+ _add_gradient_rect(slide, 0, 0, SLIDE_W, SLIDE_H, ov, angle=0, a1=14, a2=14)
470
+ _add_gradient_rect(slide, 0, 0, Inches(9.6), SLIDE_H, ov, angle=0, a1=93, a2=0)
471
+ title_color = RGBColor(0xFF, 0xFF, 0xFF) if dark else NAVY
472
+ sub_color = RGBColor(0xFF, 0xFF, 0xFF) if dark else GREY
473
+ author_color = RGBColor(0xFF, 0xFF, 0xFF) if dark else BLACK
474
+ date_color = RGBColor(0xCF, 0xD9, 0xE9) if dark else GREY
475
+ org_color = RGBColor(0xFF, 0xC7, 0x99) if dark else ORANGE
476
+ # 顶部组织/品类标签
477
+ if meta.get('org'):
478
+ tb, tf = _add_textbox(slide, Inches(0.7), Inches(0.5), Inches(11.9), Inches(0.4))
479
+ _add_para(tf, meta['org'], font=YAHEI, size=14, bold=True, color=org_color, first=True, space_after=0)
480
+ # 主标题
481
+ tb, tf = _add_textbox(slide, Inches(0.7), Inches(1.15), Inches(11.9), Inches(1.2))
482
+ _add_para(tf, meta.get('title', ''), font=TITLE_FONT, size=44, bold=True,
483
+ color=title_color, first=True, space_after=0)
484
+ # 标题下强调线
485
+ _add_rect(slide, Inches(0.72), Inches(2.4), Inches(2.2), Pt(3), fill=RED)
486
+ # 副标题
487
+ if meta.get('subtitle'):
488
+ tb2, tf2 = _add_textbox(slide, Inches(0.72), Inches(2.6), Inches(11.9), Inches(0.8))
489
+ _add_para(tf2, meta['subtitle'], font=BODY_FONT, size=21, bold=False,
490
+ color=sub_color, first=True, space_after=0)
491
+ # 作者 / 日期
492
+ y = Inches(3.6)
493
+ if meta.get('author'):
494
+ tb3, tf3 = _add_textbox(slide, Inches(0.72), y, Inches(11.9), Inches(0.5))
495
+ _add_para(tf3, meta['author'], font=BODY_FONT, size=16, bold=False,
496
+ color=author_color, first=True, space_after=0)
497
+ y = y + Inches(0.45)
498
+ if meta.get('date'):
499
+ tb4, tf4 = _add_textbox(slide, Inches(0.72), y, Inches(11.9), Inches(0.5))
500
+ _add_para(tf4, meta['date'], font=YAHEI, size=14, bold=False,
501
+ color=date_color, first=True, space_after=0)
502
+ # 底部看点条(一行多列,编号 + 关键词,铺满)
503
+ highlights = meta.get('highlights', [])
504
+ if highlights:
505
+ # 底部柔和渐隐,自然过渡到看点条(消除硬切边)
506
+ _add_gradient_rect(slide, 0, Inches(4.55), SLIDE_W, Inches(0.8), str(NAVY), angle=90, a1=0, a2=100)
507
+ band_y = Inches(5.35)
508
+ band_h = Inches(2.15)
509
+ _add_rect(slide, 0, band_y, SLIDE_W, band_h, fill=NAVY)
510
+ tb5, tf5 = _add_textbox(slide, Inches(0.7), band_y + Inches(0.16), Inches(11.9), Inches(0.35))
511
+ _add_para(tf5, meta.get('highlights_label', '本指南看点'), font=YAHEI, size=13, bold=True,
512
+ color=RGBColor(0xE2, 0xCE, 0x8C), first=True, space_after=0)
513
+ ncol = len(highlights)
514
+ cw = Inches(11.9) / ncol
515
+ for i, h in enumerate(highlights):
516
+ cx = Inches(0.7) + i * cw
517
+ cy = band_y + Inches(0.6)
518
+ tb1, tf1 = _add_textbox(slide, cx, cy, cw - Inches(0.2), Inches(0.5))
519
+ _add_para(tf1, f"{i + 1:02d}", font=TITLE_FONT, size=22, bold=True,
520
+ color=ORANGE, first=True, space_after=0)
521
+ tb2, tf2 = _add_textbox(slide, cx, cy + Inches(0.52), cw - Inches(0.3), Inches(0.8))
522
+ _add_para(tf2, h, font=YAHEI, size=13.5, bold=False,
523
+ color=RGBColor(0xFF, 0xFF, 0xFF), first=True, space_after=0, line_spacing=1.1)
524
+ return slide
525
+
526
+
527
+ def build_toc(prs, s, page=None):
528
+ slide = prs.slides.add_slide(_blank_layout(prs))
529
+ _add_rich_background(slide, page)
530
+ _add_topbar(slide)
531
+ _add_title(slide, s.get('title', '目录'), size=34, y=Inches(1.0))
532
+ items = s.get('items', [])
533
+ n = len(items)
534
+ if n == 0:
535
+ _add_footer(slide, page)
536
+ return slide
537
+ # 右侧配图(可选,页级 image)
538
+ img, _ = _resolve_image(s)
539
+ if img:
540
+ img_w = Inches(4.2)
541
+ img_x = Inches(12.88) - img_w
542
+ _add_image_framed(slide, img, img_x, Inches(2.0), img_w, Inches(4.65), radius=0.06, border=ORANGE)
543
+ list_w = Inches(7.4) if img else Inches(12.43)
544
+ top = Inches(2.0)
545
+ bottom = Inches(6.85)
546
+ y = top
547
+ intro = s.get('intro', '')
548
+ if intro:
549
+ tb, tf = _add_textbox(slide, Inches(MARGIN_X), y, list_w, Inches(0.5))
550
+ _add_para(tf, intro, font=BODY_FONT, size=14, color=GREY, first=True, space_after=0)
551
+ y = y + Inches(0.6)
552
+ row_h = (bottom - y) / max(n, 1)
553
+ for i, item in enumerate(items):
554
+ iy = y + i * row_h
555
+ # 序号圆
556
+ circ = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(MARGIN_X), iy + (row_h - Inches(0.5)) / 2, Inches(0.5), Inches(0.5))
557
+ circ.fill.solid(); circ.fill.fore_color.rgb = ORANGE; circ.line.fill.background()
558
+ circ.shadow.inherit = False
559
+ ctf = circ.text_frame; ctf.word_wrap = False
560
+ cp = ctf.paragraphs[0]; cp.alignment = PP_ALIGN.CENTER
561
+ cr = cp.add_run(); _set_run(cr, str(i + 1), font=YAHEI, size=15, bold=True,
562
+ color=RGBColor(0xFF, 0xFF, 0xFF))
563
+ # 条目文字
564
+ tb2, tf2 = _add_textbox(slide, Inches(0.78), iy + (row_h - Inches(0.5)) / 2, list_w - Inches(0.4), Inches(0.5))
565
+ _add_para(tf2, item, font=BODY_FONT, size=19, bold=False, color=NAVY, first=True, space_after=0)
566
+ # 行间细线
567
+ if i < n - 1:
568
+ _add_rect(slide, Inches(0.78), iy + row_h - Inches(0.02), list_w - Inches(0.4), Pt(1), fill=RGBColor(0xE7, 0xD3, 0xC8))
569
+ _add_footer(slide, page)
570
+ return slide
571
+
572
+
573
+ def build_section(prs, s, page=None):
574
+ """按 layout 分派到不同版式:cards / split(rev) / quote / list / banner / big / mosaic / comparison。"""
575
+ layout = s.get('layout', 'cards')
576
+ if layout == 'split':
577
+ return _build_split(prs, s, page)
578
+ if layout == 'quote':
579
+ return _build_quote(prs, s, page)
580
+ if layout == 'list':
581
+ return _build_list(prs, s, page)
582
+ if layout == 'banner':
583
+ return _build_banner(prs, s, page)
584
+ if layout == 'big':
585
+ return _build_big(prs, s, page)
586
+ if layout == 'mosaic':
587
+ return _build_mosaic(prs, s, page)
588
+ if layout == 'comparison':
589
+ return _build_comparison(prs, s, page)
590
+ return _build_cards(prs, s, page)
591
+
592
+
593
+ def _build_cards(prs, s, page=None):
594
+ """卡片网格(图文卡,铺满版心,正文加粗加大)。"""
595
+ slide = prs.slides.add_slide(_blank_layout(prs))
596
+ _add_rich_background(slide, page)
597
+ _add_topbar(slide)
598
+ _add_chapter_marker(slide, s.get('chapter'))
599
+ _add_title(slide, s.get('title'))
600
+ blocks = s.get('blocks', [])
601
+ n = len(blocks)
602
+ if n == 0:
603
+ _add_footer(slide, page)
604
+ return slide
605
+ has_takeaway = bool(s.get('takeaway'))
606
+ top = Inches(1.95)
607
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
608
+ cols, rows = _grid(n)
609
+ cells = _grid_cells(n, top, bottom, cols, rows)
610
+ for i, b in enumerate(blocks):
611
+ x, y, cw, ch = cells[i]
612
+ img, attr = _resolve_image(b)
613
+ _card(slide, x, y, cw, ch, accent=NAVY)
614
+ pad = Inches(0.16)
615
+ img_h = Emu(int(ch * 0.5))
616
+ if img_h > Inches(2.2):
617
+ img_h = Inches(2.2)
618
+ if img:
619
+ ix, iy = x + pad, y + Inches(0.18)
620
+ iw = cw - pad * 2
621
+ _add_image_framed(slide, img, ix, iy, iw, img_h, radius=0.09, border=ORANGE)
622
+ _add_gradient_rect(slide, ix, iy + img_h - Inches(0.16), iw, Inches(0.16),
623
+ str(LIGHT), angle=90, a1=0, a2=100)
624
+ ty = y + Inches(0.18) + img_h + Inches(0.12)
625
+ tb, tf = _add_textbox(slide, x + Inches(0.26), ty, cw - Inches(0.52), Inches(0.5))
626
+ _add_para(tf, b.get('heading', ''), font=BODY_FONT, size=18, bold=True,
627
+ color=NAVY, first=True, space_after=0)
628
+ note = b.get('note', '')
629
+ pts = b.get('points', [])
630
+ npts = len(pts)
631
+ note_reserve = Inches(0.46) if note else Inches(0.0)
632
+ if npts:
633
+ body_top = ty + Inches(0.52)
634
+ body_h = (y + ch) - body_top - Inches(0.14) - note_reserve
635
+ step = body_h / npts
636
+ for idx, pt in enumerate(pts):
637
+ py = body_top + idx * step
638
+ _add_bullet_icon(slide, x + Inches(0.28), py + Inches(0.06), size=0.11)
639
+ tb2, tf2 = _add_textbox(slide, x + Inches(0.52), py, cw - Inches(0.78), step - Inches(0.02))
640
+ _add_para(tf2, pt, font=BODY_FONT, size=15, bold=False,
641
+ color=NAVY, first=True, space_after=0, line_spacing=1.18)
642
+ # 卡片底部:手写感短句 或 细线+菱形点缀(填补下半留白)
643
+ if note:
644
+ ny = y + ch - Inches(0.44)
645
+ _add_rect(slide, x + Inches(0.24), ny - Inches(0.06), cw - Inches(0.48), Pt(1), fill=RGBColor(0xE7, 0xD3, 0xC8))
646
+ tn, tnf = _add_textbox(slide, x + Inches(0.26), ny + Inches(0.02), cw - Inches(0.52), Inches(0.38))
647
+ _add_para(tnf, note, font=YAHEI, size=12, bold=False, color=RGBColor(0x9A, 0x5A, 0x6A),
648
+ first=True, space_after=0, line_spacing=1.1)
649
+ elif attr:
650
+ _add_caption_chip(slide, x + Inches(0.26), y + ch - Inches(0.32), attr)
651
+ else:
652
+ ly = y + ch - Inches(0.3)
653
+ _add_rect(slide, x + Inches(0.24), ly, cw - Inches(0.48), Pt(1), fill=RGBColor(0xE7, 0xD3, 0xC8))
654
+ dm = slide.shapes.add_shape(MSO_SHAPE.DIAMOND, x + Inches(0.26), ly + Inches(0.04), Inches(0.1), Inches(0.1))
655
+ dm.fill.solid(); dm.fill.fore_color.rgb = ORANGE; dm.line.fill.background(); dm.shadow.inherit = False
656
+ _add_takeaway(slide, s.get('takeaway'))
657
+ _add_footer(slide, page)
658
+ return slide
659
+
660
+
661
+ def _build_split(prs, s, page=None):
662
+ """左右分栏:左侧标题+要点,右侧整高大图。"""
663
+ slide = prs.slides.add_slide(_blank_layout(prs))
664
+ _add_rich_background(slide, page)
665
+ _add_topbar(slide)
666
+ _add_chapter_marker(slide, s.get('chapter'))
667
+ _add_title(slide, s.get('title'))
668
+ blocks = s.get('blocks', [])
669
+ has_takeaway = bool(s.get('takeaway'))
670
+ top = Inches(1.98)
671
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
672
+ # 右侧(或左侧)整高大图(优先取页面级 image,否则取首个 block 的图)
673
+ img, attr = _resolve_image(s if s.get('image') else (blocks[0] if blocks else {}))
674
+ reverse = bool(s.get('reverse'))
675
+ img_w = Inches(4.9)
676
+ if reverse:
677
+ img_x = Inches(0.45)
678
+ tx = Inches(5.7)
679
+ else:
680
+ img_x = Inches(12.88) - img_w
681
+ tx = Inches(0.45)
682
+ if img:
683
+ _add_image_framed(slide, img, img_x, top, img_w, bottom - top, radius=0.06, border=ORANGE)
684
+ _add_gradient_rect(slide, img_x, top + (bottom - top) - Inches(0.3), img_w, Inches(0.3),
685
+ str(LIGHT), angle=90, a1=0, a2=100)
686
+ # 文字(紧凑排布,自动适配块数与要点数,不压到页脚)
687
+ lw = Inches(6.75)
688
+ total_pts = sum(len(b.get('points', [])) for b in blocks)
689
+ n_blocks = len(blocks)
690
+ avail = bottom - top - Inches(0.1)
691
+ # 动态行距:内容多则收紧
692
+ head_h = Inches(0.46)
693
+ gap_h = Inches(0.16)
694
+ pt_h = (avail - n_blocks * head_h - (n_blocks - 1) * gap_h) / max(total_pts, 1)
695
+ pt_h = max(Inches(0.36), min(Inches(0.55), pt_h))
696
+ y = top + Inches(0.05)
697
+ for bi, b in enumerate(blocks):
698
+ heading = b.get('heading', '')
699
+ points = b.get('points', [])
700
+ _add_rect(slide, tx, y + Inches(0.05), Inches(0.08), Inches(0.4), fill=ORANGE)
701
+ tb, tf = _add_textbox(slide, tx + Inches(0.27), y, lw - Inches(0.25), Inches(0.44))
702
+ _add_para(tf, heading, font=BODY_FONT, size=19, bold=True, color=NAVY, first=True, space_after=0)
703
+ y += head_h
704
+ for pt in points:
705
+ _add_bullet_icon(slide, tx + Inches(0.27), y + Inches(0.07), size=0.1)
706
+ tb2, tf2 = _add_textbox(slide, tx + Inches(0.53), y, lw - Inches(0.5), pt_h + Inches(0.05))
707
+ _add_para(tf2, pt, font=BODY_FONT, size=15, color=NAVY, first=True, space_after=0, line_spacing=1.18)
708
+ y += pt_h
709
+ y += gap_h
710
+ _add_takeaway(slide, s.get('takeaway'))
711
+ _add_footer(slide, page)
712
+ return slide
713
+
714
+
715
+ def _build_quote(prs, s, page=None):
716
+ """整页引言:满幅背景图 + 暖色蒙层 + 居中大字。"""
717
+ slide = prs.slides.add_slide(_blank_layout(prs))
718
+ _add_background(slide)
719
+ img, _ = _resolve_image(s)
720
+ if img:
721
+ _add_fullbleed(slide, img)
722
+ _add_gradient_rect(slide, 0, 0, SLIDE_W, SLIDE_H, '3A1B20', angle=0, a1=46, a2=46)
723
+ else:
724
+ _add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, fill=NAVY)
725
+ text = s.get('quote', '') or s.get('title', '')
726
+ tb, tf = _add_textbox(slide, Inches(0.9), Inches(1.75), Inches(11.53), Inches(2.7))
727
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
728
+ _add_para(tf, f"“{text}”", font=TITLE_FONT, size=31, bold=True,
729
+ color=RGBColor(0xFF, 0xFF, 0xFF), align=PP_ALIGN.CENTER, first=True, space_after=0, line_spacing=1.28)
730
+ sub = s.get('sub', '')
731
+ if sub:
732
+ tb2, tf2 = _add_textbox(slide, Inches(1.4), Inches(4.55), Inches(10.53), Inches(1.1))
733
+ _add_para(tf2, sub, font=BODY_FONT, size=18, color=RGBColor(0xFF, 0xE3, 0xC7),
734
+ align=PP_ALIGN.CENTER, first=True, space_after=0, line_spacing=1.3)
735
+ # 底部短横线点缀
736
+ _add_rect(slide, Inches(6.17), Inches(4.35), Inches(1.0), Pt(2.5), fill=ORANGE)
737
+ if page is not None:
738
+ tb3, tf3 = _add_textbox(slide, Inches(12.2), Inches(7.0), Inches(0.7), Inches(0.3))
739
+ _add_para(tf3, str(page), font=YAHEI, size=10, color=RGBColor(0xFF, 0xFF, 0xFF),
740
+ align=PP_ALIGN.RIGHT, first=True, space_after=0)
741
+ return slide
742
+
743
+
744
+ def _build_list(prs, s, page=None):
745
+ """大序号横条列表:每行 = 序号色块 + 标题要点(内联) + 右侧缩略图。"""
746
+ slide = prs.slides.add_slide(_blank_layout(prs))
747
+ _add_rich_background(slide, page)
748
+ _add_topbar(slide)
749
+ _add_chapter_marker(slide, s.get('chapter'))
750
+ _add_title(slide, s.get('title'))
751
+ blocks = s.get('blocks', [])
752
+ n = len(blocks)
753
+ has_takeaway = bool(s.get('takeaway'))
754
+ top = Inches(1.98)
755
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
756
+ gap = Inches(0.2)
757
+ row_h = (bottom - top - gap * (n - 1)) / n if n else Inches(1.0)
758
+ for i, b in enumerate(blocks):
759
+ y = top + i * (row_h + gap)
760
+ # 行底色 + 左侧序号色块
761
+ _add_rect(slide, Inches(0.45), y, Inches(12.43), row_h, fill=RGBColor(0xFF, 0xFF, 0xFF),
762
+ line=RGBColor(0xE4, 0xD0, 0xC6))
763
+ _add_rect(slide, Inches(0.45), y, Inches(1.4), row_h, fill=ORANGE)
764
+ tbn = slide.shapes.add_textbox(Inches(0.45), y + (row_h - Inches(0.6)) / 2, Inches(1.4), Inches(0.6)).text_frame
765
+ tbn.word_wrap = False
766
+ pn = tbn.paragraphs[0]; pn.alignment = PP_ALIGN.CENTER
767
+ rn = pn.add_run(); _set_run(rn, f"{i + 1:02d}", font=TITLE_FONT, size=27, bold=True,
768
+ color=RGBColor(0xFF, 0xFF, 0xFF))
769
+ # 缩略图(右侧)
770
+ img, _ = _resolve_image(b)
771
+ thumb_w = Inches(1.8) if img else Inches(0.0)
772
+ tx = Inches(2.15)
773
+ tw = Inches(12.88) - tx - (thumb_w + Inches(0.42) if img else Inches(0.32))
774
+ # 标题(粗) + 要点(内联),垂直居中
775
+ head = b.get('heading', '')
776
+ pts = b.get('points', [])
777
+ tb, tf = _add_textbox(slide, tx, y, tw, row_h)
778
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
779
+ p = tf.paragraphs[0]; p.line_spacing = 1.18
780
+ r1 = p.add_run(); _set_run(r1, head, font=BODY_FONT, size=17, bold=True, color=NAVY)
781
+ if pts:
782
+ r2 = p.add_run(); _set_run(r2, " " + " · ".join(pts), font=BODY_FONT, size=14.5,
783
+ bold=False, color=NAVY)
784
+ if img:
785
+ _add_image_framed(slide, img, Inches(12.88) - thumb_w - Inches(0.12), y + Inches(0.12),
786
+ thumb_w, row_h - Inches(0.24), radius=0.1, border=ORANGE)
787
+ _add_takeaway(slide, s.get('takeaway'))
788
+ _add_footer(slide, page)
789
+ return slide
790
+
791
+
792
+ def _build_banner(prs, s, page=None):
793
+ """整宽大图横幅 + 下方横排要点(区别于卡片网格)。"""
794
+ slide = prs.slides.add_slide(_blank_layout(prs))
795
+ _add_rich_background(slide, page)
796
+ _add_topbar(slide)
797
+ _add_chapter_marker(slide, s.get('chapter'))
798
+ _add_title(slide, s.get('title'))
799
+ blocks = s.get('blocks', [])
800
+ has_takeaway = bool(s.get('takeaway'))
801
+ img, _ = _resolve_image(s)
802
+ if img:
803
+ _add_image_framed(slide, img, Inches(0.45), Inches(1.92), Inches(12.43), Inches(2.45), radius=0.05, border=ORANGE)
804
+ _add_gradient_rect(slide, Inches(0.45), Inches(4.37) - Inches(0.22), Inches(12.43), Inches(0.22),
805
+ str(LIGHT), angle=90, a1=0, a2=100)
806
+ top = Inches(4.6)
807
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
808
+ n = len(blocks)
809
+ row_h = (bottom - top) / max(n, 1)
810
+ for i, b in enumerate(blocks):
811
+ y = top + i * row_h
812
+ head = b.get('heading', '')
813
+ pts = b.get('points', [])
814
+ _add_rect(slide, Inches(0.45), y + Inches(0.1), Inches(0.09), row_h - Inches(0.2), fill=ORANGE)
815
+ tb, tf = _add_textbox(slide, Inches(0.72), y, Inches(11.9), row_h)
816
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
817
+ p = tf.paragraphs[0]; p.line_spacing = 1.15
818
+ r1 = p.add_run(); _set_run(r1, head, font=BODY_FONT, size=18, bold=True, color=NAVY)
819
+ if pts:
820
+ r2 = p.add_run(); _set_run(r2, " " + " · ".join(pts), font=BODY_FONT, size=15, color=NAVY)
821
+ _add_takeaway(slide, s.get('takeaway'))
822
+ _add_footer(slide, page)
823
+ return slide
824
+
825
+
826
+ def _build_big(prs, s, page=None):
827
+ """大字强调页:左侧超大关键词 + 右侧要点卡片。"""
828
+ slide = prs.slides.add_slide(_blank_layout(prs))
829
+ _add_rich_background(slide, page)
830
+ _add_topbar(slide)
831
+ _add_chapter_marker(slide, s.get('chapter'))
832
+ big = s.get('big', '')
833
+ sub = s.get('sub', '')
834
+ points = s.get('points', [])
835
+ # 左侧超大词
836
+ tb, tf = _add_textbox(slide, Inches(0.6), Inches(1.7), Inches(6.3), Inches(3.0))
837
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
838
+ _add_para(tf, big, font=TITLE_FONT, size=100, bold=True, color=NAVY, first=True, space_after=0)
839
+ _add_rect(slide, Inches(0.66), Inches(4.05), Inches(1.6), Pt(3), fill=ORANGE)
840
+ if sub:
841
+ tb2, tf2 = _add_textbox(slide, Inches(0.62), Inches(4.25), Inches(6.1), Inches(0.7))
842
+ _add_para(tf2, sub, font=BODY_FONT, size=18, color=GREY, first=True, space_after=0, line_spacing=1.2)
843
+ # 右侧要点竖排卡片
844
+ x = Inches(7.35)
845
+ w = Inches(5.55)
846
+ top = Inches(1.98)
847
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if s.get('takeaway') else CONTENT_BOTTOM)
848
+ n = len(points)
849
+ gap = Inches(0.22)
850
+ row_h = (bottom - top - gap * (n - 1)) / max(n, 1)
851
+ for i, pt in enumerate(points):
852
+ y = top + i * (row_h + gap)
853
+ _add_rect(slide, x, y, w, row_h, fill=RGBColor(0xFF, 0xFF, 0xFF), line=RGBColor(0xE4, 0xD0, 0xC6))
854
+ _add_rect(slide, x, y, Inches(0.08), row_h, fill=ORANGE)
855
+ tb3, tf3 = _add_textbox(slide, x + Inches(0.3), y, w - Inches(0.5), row_h)
856
+ tf3.vertical_anchor = MSO_ANCHOR.MIDDLE
857
+ _add_para(tf3, pt, font=BODY_FONT, size=16, color=NAVY, first=True, space_after=0, line_spacing=1.2)
858
+ _add_takeaway(slide, s.get('takeaway'))
859
+ _add_footer(slide, page)
860
+ return slide
861
+
862
+
863
+ def _build_mosaic(prs, s, page=None):
864
+ """图片拼贴:左侧大图 + 右侧上下两张小图,各带标题。"""
865
+ slide = prs.slides.add_slide(_blank_layout(prs))
866
+ _add_rich_background(slide, page)
867
+ _add_topbar(slide)
868
+ _add_chapter_marker(slide, s.get('chapter'))
869
+ _add_title(slide, s.get('title'))
870
+ blocks = s.get('blocks', [])
871
+ has_takeaway = bool(s.get('takeaway'))
872
+ top = Inches(1.95)
873
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
874
+ H = bottom - top
875
+ # 左大图
876
+ if len(blocks) >= 1:
877
+ img, _ = _resolve_image(blocks[0])
878
+ if img:
879
+ _add_image_framed(slide, img, Inches(0.45), top, Inches(7.0), H, radius=0.05, border=ORANGE)
880
+ cap = blocks[0].get('heading', '')
881
+ if cap:
882
+ _add_rect(slide, Inches(0.45), top + H - Inches(0.46), Inches(7.0), Inches(0.46), fill=NAVY)
883
+ tb, tf = _add_textbox(slide, Inches(0.62), top + H - Inches(0.42), Inches(6.6), Inches(0.36))
884
+ _add_para(tf, cap, font=BODY_FONT, size=14, bold=True, color=RGBColor(0xFF, 0xFF, 0xFF), first=True, space_after=0)
885
+ # 右侧两小图
886
+ small = blocks[1:3]
887
+ sw = Inches(5.0)
888
+ sx = Inches(7.75)
889
+ sh = (H - Inches(0.25)) / max(len(small), 1)
890
+ for i, b in enumerate(small):
891
+ y = top + i * (sh + Inches(0.25))
892
+ img, _ = _resolve_image(b)
893
+ if img:
894
+ _add_image_framed(slide, img, sx, y, sw, sh, radius=0.06, border=ORANGE)
895
+ cap = b.get('heading', '')
896
+ if cap:
897
+ _add_rect(slide, sx, y + sh - Inches(0.4), sw, Inches(0.4), fill=NAVY)
898
+ tb, tf = _add_textbox(slide, sx + Inches(0.18), y + sh - Inches(0.36), sw - Inches(0.36), Inches(0.32))
899
+ _add_para(tf, cap, font=BODY_FONT, size=13, bold=True, color=RGBColor(0xFF, 0xFF, 0xFF), first=True, space_after=0)
900
+ _add_takeaway(slide, s.get('takeaway'))
901
+ _add_footer(slide, page)
902
+ return slide
903
+
904
+
905
+ def _build_comparison(prs, s, page=None):
906
+ """双栏对比:冷调 vs 暖调(两种语气 / 两种做法)。"""
907
+ slide = prs.slides.add_slide(_blank_layout(prs))
908
+ _add_rich_background(slide, page)
909
+ _add_topbar(slide)
910
+ _add_chapter_marker(slide, s.get('chapter'))
911
+ _add_title(slide, s.get('title'))
912
+ blocks = s.get('blocks', [])
913
+ has_takeaway = bool(s.get('takeaway'))
914
+ top = Inches(1.95)
915
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
916
+ col_w = Inches(6.0)
917
+ gap = Inches(0.43)
918
+ for i, b in enumerate(blocks[:2]):
919
+ x = Inches(0.45) + i * (col_w + gap)
920
+ cold = (i == 0)
921
+ bg = RGBColor(0xED, 0xE7, 0xE6) if cold else RGBColor(0xFB, 0xEC, 0xE3)
922
+ accent = RGBColor(0x8A, 0x93, 0xA6) if cold else ORANGE
923
+ _add_rect(slide, x, top, col_w, bottom - top, fill=bg, line=RGBColor(0xE2, 0xD5, 0xCF))
924
+ _add_rect(slide, x, top, col_w, Inches(0.12), fill=accent)
925
+ tb, tf = _add_textbox(slide, x + Inches(0.32), top + Inches(0.32), col_w - Inches(0.64), Inches(0.5))
926
+ _add_para(tf, b.get('heading', ''), font=BODY_FONT, size=20, bold=True, color=NAVY, first=True, space_after=0)
927
+ py = top + Inches(0.95)
928
+ for pt in b.get('points', []):
929
+ tb2, tf2 = _add_textbox(slide, x + Inches(0.32), py, col_w - Inches(0.64), Inches(0.9))
930
+ _add_para(tf2, "“" + pt + "”", font=BODY_FONT, size=15.5, color=NAVY, first=True, space_after=0, line_spacing=1.22)
931
+ py += Inches(0.85)
932
+ _add_takeaway(slide, s.get('takeaway'))
933
+ _add_footer(slide, page)
934
+ return slide
935
+
936
+
937
+ def build_bullets(prs, s, page=None):
938
+ slide = prs.slides.add_slide(_blank_layout(prs))
939
+ _add_background(slide)
940
+ _add_topbar(slide)
941
+ _add_chapter_marker(slide, s.get('chapter'))
942
+ _add_title(slide, s.get('title'))
943
+ has_takeaway = bool(s.get('takeaway'))
944
+ top = Inches(1.95)
945
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
946
+ y = top
947
+ intro = s.get('intro', '')
948
+ if intro:
949
+ tb, tf = _add_textbox(slide, Inches(MARGIN_X), y, Inches(12.43), Inches(0.5))
950
+ _add_para(tf, intro, font=BODY_FONT, size=14, color=GREY, first=True, space_after=0)
951
+ y = y + Inches(0.62)
952
+ items = s.get('items', [])
953
+ n = len(items)
954
+ if n:
955
+ avail = bottom - y
956
+ step = avail / n
957
+ if n >= 5:
958
+ # 网格模式(5+ 条):两列卡片,铺满宽度与高度
959
+ cols = 2
960
+ rows = math.ceil(n / cols)
961
+ gap = Inches(0.25)
962
+ cw = (Inches(12.43) - gap * (cols - 1)) / cols
963
+ ch = (bottom - y - gap * (rows - 1)) / rows
964
+ for i, it in enumerate(items):
965
+ c = i % cols
966
+ r = i // cols
967
+ x = Inches(MARGIN_X) + c * (cw + gap)
968
+ iy = y + r * (ch + gap)
969
+ head = it.get('head', '')
970
+ text = it.get('text', '')
971
+ _add_rect(slide, x, iy, cw, ch, fill=LIGHT)
972
+ _add_rect(slide, x, iy, Inches(0.09), ch, fill=NAVY)
973
+ circ = slide.shapes.add_shape(MSO_SHAPE.OVAL, x + Inches(0.28), iy + (ch - Inches(0.42)) / 2, Inches(0.42), Inches(0.42))
974
+ circ.fill.solid(); circ.fill.fore_color.rgb = NAVY; circ.line.fill.background()
975
+ circ.shadow.inherit = False
976
+ ctf = circ.text_frame; ctf.word_wrap = False
977
+ cp = ctf.paragraphs[0]; cp.alignment = PP_ALIGN.CENTER
978
+ cr = cp.add_run(); _set_run(cr, str(i + 1), font=YAHEI, size=13, bold=True,
979
+ color=RGBColor(0xFF, 0xFF, 0xFF))
980
+ tb, tf = _add_textbox(slide, x + Inches(0.88), iy + Inches(0.12), cw - Inches(1.05), Inches(0.4))
981
+ _add_para(tf, head, font=BODY_FONT, size=14.5, bold=True, color=NAVY, first=True, space_after=0)
982
+ tb2, tf2 = _add_textbox(slide, x + Inches(0.88), iy + Inches(0.52), cw - Inches(1.05), ch - Inches(0.64))
983
+ _add_para(tf2, text, font=BODY_FONT, size=12, color=NAVY,
984
+ first=True, space_after=0, line_spacing=1.1)
985
+ else:
986
+ # 卡片模式(<=4 条)
987
+ for i, it in enumerate(items):
988
+ iy = y + i * step
989
+ head = it.get('head', '')
990
+ text = it.get('text', '')
991
+ ch = step - Inches(0.14)
992
+ _add_rect(slide, Inches(MARGIN_X), iy, Inches(12.43), ch, fill=LIGHT)
993
+ _add_rect(slide, Inches(MARGIN_X), iy, Inches(0.09), ch, fill=NAVY)
994
+ circ = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(0.88), iy + (ch - Inches(0.5)) / 2, Inches(0.5), Inches(0.5))
995
+ circ.fill.solid(); circ.fill.fore_color.rgb = NAVY; circ.line.fill.background()
996
+ circ.shadow.inherit = False
997
+ ctf = circ.text_frame; ctf.word_wrap = False
998
+ cp = ctf.paragraphs[0]; cp.alignment = PP_ALIGN.CENTER
999
+ cr = cp.add_run(); _set_run(cr, str(i + 1), font=YAHEI, size=16, bold=True,
1000
+ color=RGBColor(0xFF, 0xFF, 0xFF))
1001
+ tb, tf = _add_textbox(slide, Inches(1.65), iy + Inches(0.12), Inches(11.0), Inches(0.42))
1002
+ _add_para(tf, head, font=BODY_FONT, size=16, bold=True, color=NAVY, first=True, space_after=0)
1003
+ tb2, tf2 = _add_textbox(slide, Inches(1.65), iy + Inches(0.56), Inches(11.0), ch - Inches(0.66))
1004
+ _add_para(tf2, text, font=BODY_FONT, size=13, color=NAVY,
1005
+ first=True, space_after=0, line_spacing=1.15)
1006
+ _add_takeaway(slide, s.get('takeaway'))
1007
+ _add_footer(slide, page)
1008
+ return slide
1009
+
1010
+
1011
+ def build_table(prs, s, page=None):
1012
+ slide = prs.slides.add_slide(_blank_layout(prs))
1013
+ _add_background(slide)
1014
+ _add_topbar(slide)
1015
+ _add_chapter_marker(slide, s.get('chapter'))
1016
+ _add_title(slide, s.get('title'))
1017
+ headers = s.get('headers', [])
1018
+ rows = s.get('rows', [])
1019
+ n_rows = len(rows) + 1
1020
+ n_cols = len(headers)
1021
+ if n_cols == 0:
1022
+ _add_footer(slide, page)
1023
+ return slide
1024
+ left, top = Inches(MARGIN_X), Inches(1.95)
1025
+ width, height = Inches(12.43), Inches(0.58) * n_rows
1026
+ gtable = slide.shapes.add_table(n_rows, n_cols, left, top, width, height)
1027
+ table = gtable.table
1028
+ for c, h in enumerate(headers):
1029
+ cell = table.cell(0, c)
1030
+ cell.text = h
1031
+ cell.fill.solid(); cell.fill.fore_color.rgb = NAVY
1032
+ for p in cell.text_frame.paragraphs:
1033
+ p.alignment = PP_ALIGN.CENTER
1034
+ for r in p.runs:
1035
+ _set_run(r, h, font=YAHEI, size=13, bold=True, color=RGBColor(0xFF, 0xFF, 0xFF))
1036
+ for ri, row in enumerate(rows, 1):
1037
+ for ci, val in enumerate(row):
1038
+ cell = table.cell(ri, ci)
1039
+ cell.text = str(val)
1040
+ cell.fill.solid()
1041
+ cell.fill.fore_color.rgb = LIGHT if ri % 2 == 0 else RGBColor(0xFF, 0xFF, 0xFF)
1042
+ for p in cell.text_frame.paragraphs:
1043
+ p.alignment = PP_ALIGN.CENTER if ci > 0 else PP_ALIGN.LEFT
1044
+ for r in p.runs:
1045
+ _set_run(r, str(val), font=YAHEI, size=12.5, bold=False, color=BLACK)
1046
+ note = s.get('note', '')
1047
+ if note:
1048
+ tb, tf = _add_textbox(slide, Inches(MARGIN_X), top + Inches(0.58) * n_rows + Inches(0.06),
1049
+ Inches(12.43), Inches(0.4))
1050
+ _add_para(tf, note, font=YAHEI, size=11, color=GREY, first=True, space_after=0)
1051
+ _add_takeaway(slide, s.get('takeaway'))
1052
+ _add_footer(slide, page)
1053
+ return slide
1054
+
1055
+
1056
+ def build_process(prs, s, page=None):
1057
+ slide = prs.slides.add_slide(_blank_layout(prs))
1058
+ _add_background(slide)
1059
+ _add_topbar(slide)
1060
+ _add_chapter_marker(slide, s.get('chapter'))
1061
+ _add_title(slide, s.get('title'))
1062
+ steps = s.get('steps', [])
1063
+ n = len(steps)
1064
+ if n == 0:
1065
+ _add_footer(slide, page)
1066
+ return slide
1067
+ has_takeaway = bool(s.get('takeaway'))
1068
+ top = Inches(CONTENT_TOP)
1069
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
1070
+ if n <= 4:
1071
+ cols, rows = n, 1
1072
+ else:
1073
+ cols, rows = 3, math.ceil(n / 3)
1074
+ # 步骤卡片内容偏少时收缩高度并垂直居中
1075
+ need_h = Inches(2.15 if n <= 4 else 3.0)
1076
+ avail = bottom - top
1077
+ if need_h < avail:
1078
+ off = (avail - need_h) / 2
1079
+ top += off
1080
+ bottom -= off
1081
+ cells = _grid_cells(n, top, bottom, cols, rows)
1082
+ for i, st in enumerate(steps):
1083
+ x, y, cw, ch = cells[i]
1084
+ _card(slide, x, y, cw, ch, accent=ORANGE)
1085
+ tb, tf = _add_textbox(slide, x + Inches(0.22), y + Inches(0.16), cw - Inches(0.44), Inches(0.5))
1086
+ _add_para(tf, st.get('n', str(i + 1)), font=TITLE_FONT, size=22, bold=True,
1087
+ color=ORANGE, first=True, space_after=0)
1088
+ tb2, tf2 = _add_textbox(slide, x + Inches(0.22), y + Inches(0.66), cw - Inches(0.44), Inches(0.5))
1089
+ _add_para(tf2, st.get('head', ''), font=BODY_FONT, size=16, bold=True,
1090
+ color=NAVY, first=True, space_after=0)
1091
+ tb3, tf3 = _add_textbox(slide, x + Inches(0.22), y + Inches(1.2), cw - Inches(0.44), ch - Inches(1.42))
1092
+ _add_para(tf3, st.get('text', ''), font=BODY_FONT, size=13.5, color=NAVY,
1093
+ first=True, space_after=0, line_spacing=1.15)
1094
+ _add_takeaway(slide, s.get('takeaway'))
1095
+ _add_footer(slide, page)
1096
+ return slide
1097
+
1098
+
1099
+ def build_chart(prs, s, page=None):
1100
+ slide = prs.slides.add_slide(_blank_layout(prs))
1101
+ _add_background(slide)
1102
+ _add_topbar(slide)
1103
+ _add_chapter_marker(slide, s.get('chapter'))
1104
+ _add_title(slide, s.get('title'))
1105
+ cs = s.get('chart', {})
1106
+ categories = cs.get('categories', [])
1107
+ series_list = cs.get('series', [])
1108
+ if not categories or not series_list:
1109
+ _add_footer(slide, page)
1110
+ return slide
1111
+ cd = CategoryChartData()
1112
+ cd.categories = categories
1113
+ for ser in series_list:
1114
+ cd.add_series(ser.get('name', ''), ser.get('values', []))
1115
+ type_map = {
1116
+ 'column': XL_CHART_TYPE.COLUMN_CLUSTERED,
1117
+ 'bar': XL_CHART_TYPE.BAR_CLUSTERED,
1118
+ 'line': XL_CHART_TYPE.LINE_MARKERS,
1119
+ 'pie': XL_CHART_TYPE.PIE,
1120
+ }
1121
+ kind = cs.get('kind', 'column')
1122
+ chart_type = type_map.get(kind, XL_CHART_TYPE.COLUMN_CLUSTERED)
1123
+ has_takeaway = bool(s.get('takeaway'))
1124
+ note = s.get('note', '')
1125
+ chart_top = Inches(1.95)
1126
+ chart_bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
1127
+ if note:
1128
+ chart_bottom = chart_bottom - Inches(0.38)
1129
+ gframe = slide.shapes.add_chart(chart_type, Inches(0.6), chart_top, Inches(12.2), chart_bottom - chart_top, cd)
1130
+ chart = gframe.chart
1131
+ chart.has_legend = (len(series_list) > 1) or (kind == 'pie')
1132
+ if chart.has_legend:
1133
+ chart.legend.position = XL_LEGEND_POSITION.BOTTOM
1134
+ chart.legend.include_in_layout = False
1135
+ palette = _ACTIVE_SERIES
1136
+ for idx, ser in enumerate(chart.series):
1137
+ try:
1138
+ ser.format.fill.solid()
1139
+ ser.format.fill.fore_color.rgb = palette[idx % len(palette)]
1140
+ except Exception:
1141
+ pass
1142
+ if note:
1143
+ tb, tf = _add_textbox(slide, Inches(0.6), chart_bottom + Inches(0.02), Inches(12.2), Inches(0.34))
1144
+ _add_para(tf, note, font=YAHEI, size=11, color=GREY, first=True, space_after=0)
1145
+ _add_takeaway(slide, s.get('takeaway'))
1146
+ _add_footer(slide, page)
1147
+ return slide
1148
+
1149
+
1150
+ def build_image(prs, s, page=None):
1151
+ slide = prs.slides.add_slide(_blank_layout(prs))
1152
+ _add_background(slide)
1153
+ _add_topbar(slide)
1154
+ _add_chapter_marker(slide, s.get('chapter'))
1155
+ _add_title(slide, s.get('title'))
1156
+ has_takeaway = bool(s.get('takeaway'))
1157
+ bottom = Inches(CONTENT_BOTTOM_TAKEAWAY if has_takeaway else CONTENT_BOTTOM)
1158
+ img_path, attr = _resolve_image(s)
1159
+ img_bottom = Inches(2.0)
1160
+ if img_path and os.path.exists(img_path):
1161
+ area_top = Inches(2.0)
1162
+ iw = Inches(12.43)
1163
+ ih = bottom - area_top - Inches(0.5)
1164
+ # 全宽横幅图(中心裁剪不变形)+ 投影描边 + 底部渐变融入
1165
+ _add_image_framed(slide, img_path, Inches(MARGIN_X), Inches(2.0), iw, ih, radius=0.03, border=ORANGE)
1166
+ _add_gradient_rect(slide, Inches(MARGIN_X), Inches(2.0) + ih - Inches(0.3), iw, Inches(0.3),
1167
+ str(BG), angle=90, a1=0, a2=100)
1168
+ img_bottom = Inches(2.0) + ih
1169
+ caption = s.get('caption', '')
1170
+ if attr:
1171
+ caption = f"{caption} | {attr}".strip(' | ')
1172
+ if caption:
1173
+ tb, tf = _add_textbox(slide, Inches(0.9), img_bottom + Inches(0.06), Inches(11.5), Inches(0.4))
1174
+ _add_para(tf, caption, font=YAHEI, size=11.5, color=GREY,
1175
+ align=PP_ALIGN.CENTER, first=True, space_after=0)
1176
+ _add_takeaway(slide, s.get('takeaway'))
1177
+ _add_footer(slide, page)
1178
+ return slide
1179
+
1180
+
1181
+ def build_closing(prs, s, page=None):
1182
+ slide = prs.slides.add_slide(_blank_layout(prs))
1183
+ _add_background(slide)
1184
+ # 可选:整页温暖背景图 + 浅色蒙层(保持文字可读,同时温暖有图)
1185
+ img_path, _ = _resolve_image(s)
1186
+ if img_path and os.path.exists(img_path):
1187
+ _add_fullbleed(slide, img_path)
1188
+ _add_gradient_rect(slide, 0, 0, SLIDE_W, SLIDE_H, 'FFFFFF', angle=0, a1=80, a2=80)
1189
+ _add_topbar(slide)
1190
+ _add_rect(slide, 0, Inches(7.38), SLIDE_W, Inches(0.12), fill=NAVY)
1191
+ text = s.get('text', '谢谢观看')
1192
+ sub = s.get('subtitle', '')
1193
+ # 主文字
1194
+ tb, tf = _add_textbox(slide, Inches(0.5), Inches(1.15), Inches(12.3), Inches(0.9))
1195
+ _add_para(tf, text, font=TITLE_FONT, size=40, bold=True, color=NAVY,
1196
+ align=PP_ALIGN.CENTER, first=True, space_after=0)
1197
+ if sub:
1198
+ tb2, tf2 = _add_textbox(slide, Inches(0.5), Inches(2.1), Inches(12.3), Inches(0.6))
1199
+ _add_para(tf2, sub, font=BODY_FONT, size=18, color=GREY,
1200
+ align=PP_ALIGN.CENTER, first=True, space_after=0)
1201
+ # 红 + 金 双色分隔线
1202
+ _add_rect(slide, Inches(5.9), Inches(1.98), Inches(1.5), Pt(2.5), fill=RED)
1203
+ _add_rect(slide, Inches(5.9), Inches(2.1), Inches(1.5), Pt(1.2), fill=ORANGE)
1204
+ # 核心回顾数字卡(铺满中段)
1205
+ stats = s.get('stats', [])
1206
+ if stats:
1207
+ sy = Inches(2.55)
1208
+ ncol = len(stats)
1209
+ gap = Inches(0.25)
1210
+ cw = (Inches(12.43) - gap * (ncol - 1)) / ncol
1211
+ ch = Inches(1.3)
1212
+ for i, st in enumerate(stats):
1213
+ x = Inches(MARGIN_X) + i * (cw + gap)
1214
+ _card(slide, x, sy, cw, ch, accent=NAVY)
1215
+ n = st.get('n', '') if isinstance(st, dict) else str(st)
1216
+ label = st.get('label', '') if isinstance(st, dict) else ''
1217
+ tb, tf = _add_textbox(slide, x + Inches(0.15), sy + Inches(0.1), cw - Inches(0.3), Inches(0.6))
1218
+ _add_para(tf, n, font=TITLE_FONT, size=30, bold=True, color=ORANGE,
1219
+ align=PP_ALIGN.CENTER, first=True, space_after=0)
1220
+ tb2, tf2 = _add_textbox(slide, x + Inches(0.15), sy + Inches(0.74), cw - Inches(0.3), Inches(0.42))
1221
+ _add_para(tf2, label, font=YAHEI, size=12, color=GREY,
1222
+ align=PP_ALIGN.CENTER, first=True, space_after=0)
1223
+ # 关键行动条
1224
+ actions = s.get('actions', [])
1225
+ if actions:
1226
+ y = Inches(4.15)
1227
+ h = Inches(1.95)
1228
+ _add_rect(slide, Inches(MARGIN_X), y, Inches(12.43), h, fill=LIGHT)
1229
+ cols = len(actions)
1230
+ cw = Inches(12.43) / cols
1231
+ for i, a in enumerate(actions):
1232
+ x = Inches(MARGIN_X) + i * cw
1233
+ if i > 0:
1234
+ _add_rect(slide, x, y + Inches(0.4), Pt(1), h - Inches(0.8), fill=RGBColor(0xC9, 0xD2, 0xDE))
1235
+ head = a.get('head', '') if isinstance(a, dict) else ''
1236
+ atext = a.get('text', '') if isinstance(a, dict) else a
1237
+ tb, tf = _add_textbox(slide, x + Inches(0.32), y + Inches(0.24), cw - Inches(0.62), Inches(0.5))
1238
+ _add_para(tf, f"{i + 1:02d} {head}", font=BODY_FONT, size=16, bold=True,
1239
+ color=ORANGE, first=True, space_after=0)
1240
+ tb2, tf2 = _add_textbox(slide, x + Inches(0.32), y + Inches(0.78), cw - Inches(0.62), Inches(0.95))
1241
+ _add_para(tf2, atext, font=BODY_FONT, size=13, color=NAVY,
1242
+ first=True, space_after=0, line_spacing=1.2)
1243
+ # 底部署名/说明
1244
+ note = s.get('note', '')
1245
+ if note:
1246
+ tb, tf = _add_textbox(slide, Inches(0.5), Inches(6.35), Inches(12.3), Inches(0.5))
1247
+ _add_para(tf, note, font=YAHEI, size=11.5, color=GREY,
1248
+ align=PP_ALIGN.CENTER, first=True, space_after=0)
1249
+ _add_footer(slide, page)
1250
+ return slide
1251
+
1252
+
1253
+ BUILDERS = {
1254
+ 'toc': build_toc,
1255
+ 'section': build_section,
1256
+ 'bullets': build_bullets,
1257
+ 'table': build_table,
1258
+ 'chart': build_chart,
1259
+ 'image': build_image,
1260
+ 'process': build_process,
1261
+ 'closing': build_closing,
1262
+ }
1263
+
1264
+
1265
+ def _blank_layout(prs):
1266
+ """选一个接近空白的 layout(去掉标题占位符干扰)。"""
1267
+ for ly in prs.slide_layouts:
1268
+ if 'blank' in (ly.name or '').lower() or '空白' in (ly.name or ''):
1269
+ return ly
1270
+ return prs.slide_layouts[-1]
1271
+
1272
+
1273
+ def _delete_all_slides(prs):
1274
+ sldIdLst = prs.slides._sldIdLst
1275
+ for sldId in list(sldIdLst):
1276
+ rId = sldId.get(qn('r:id'))
1277
+ if rId:
1278
+ try:
1279
+ prs.part.drop_rel(rId)
1280
+ except Exception:
1281
+ pass
1282
+ sldIdLst.remove(sldId)
1283
+
1284
+
1285
+ def generate(spec, template_path, out_path):
1286
+ meta = spec.get('meta', {})
1287
+ _apply_palette(meta.get('palette', 'original'))
1288
+ global DECK_TITLE
1289
+ DECK_TITLE = (meta.get('title') or '').strip()
1290
+
1291
+ prs = Presentation(template_path)
1292
+ prs.slide_width = SLIDE_W
1293
+ prs.slide_height = SLIDE_H
1294
+ _delete_all_slides(prs)
1295
+
1296
+ slides = spec.get('slides', [])
1297
+ for page, s in enumerate(slides, 1):
1298
+ stype = s.get('type', 'section')
1299
+ if stype == 'cover':
1300
+ build_cover(prs, meta, page)
1301
+ else:
1302
+ builder = BUILDERS.get(stype, build_section)
1303
+ builder(prs, s, page)
1304
+ notes = s.get('notes') if stype != 'cover' else meta.get('notes')
1305
+ if notes:
1306
+ try:
1307
+ prs.slides[-1].notes_slide.notes_text_frame.text = notes
1308
+ except Exception:
1309
+ pass
1310
+
1311
+ prs.save(out_path)
1312
+ return out_path
1313
+
1314
+
1315
+ def _load_config():
1316
+ """读取本目录 config.json(可选),失败返回空 dict。"""
1317
+ p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.json')
1318
+ try:
1319
+ return json.load(open(p, encoding='utf-8'))
1320
+ except Exception:
1321
+ return {}
1322
+
1323
+
1324
+ def _resolve_path(p):
1325
+ """把 config 里的相对路径解析到脚本所在目录;空值返回空串。"""
1326
+ if not p:
1327
+ return ''
1328
+ if os.path.isabs(p):
1329
+ return p
1330
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), p)
1331
+
1332
+
1333
+ def _default_template():
1334
+ """默认模板:config.json 的 template → 内置 assets/base_template.pptx。"""
1335
+ t = _resolve_path(_load_config().get('template', ''))
1336
+ if t and os.path.exists(t):
1337
+ return t
1338
+ t = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'base_template.pptx')
1339
+ return t if os.path.exists(t) else ''
1340
+
1341
+
1342
+ if __name__ == '__main__':
1343
+ if len(sys.argv) < 3:
1344
+ print("用法: python3 generate_ppt.py spec.json out.pptx [--template t.pptx]")
1345
+ sys.exit(1)
1346
+ spec_path = sys.argv[1]
1347
+ out_path = sys.argv[2]
1348
+ template = _default_template()
1349
+ if '--template' in sys.argv:
1350
+ template = sys.argv[sys.argv.index('--template') + 1]
1351
+ if not template or not os.path.exists(template):
1352
+ print("错误: 未找到模板 .pptx。请用 --template 指定,或在 config.json 的 template 里配置。")
1353
+ sys.exit(1)
1354
+ spec = json.load(open(spec_path, encoding='utf-8'))
1355
+ generate(spec, template, out_path)
1356
+ print(f"已生成: {out_path}")