@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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/asset_catalog.py +159 -0
- package/assets/base_template.pptx +0 -0
- package/assets/cover_deco.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/slide2_right.png +0 -0
- package/assets/theme_apricot.jpg +0 -0
- package/assets/theme_blush.jpg +0 -0
- package/assets/theme_coral.jpg +0 -0
- package/assets/theme_glow.jpg +0 -0
- package/assets/theme_mauve.jpg +0 -0
- package/assets/theme_peach.jpg +0 -0
- package/assets/theme_rose.jpg +0 -0
- package/assets/theme_sunset.jpg +0 -0
- package/assets/topright_deco.png +0 -0
- package/build_deck.py +2288 -0
- package/charts.py +329 -0
- package/check.py +236 -0
- package/config.json +5 -0
- package/contact_sheet.py +88 -0
- package/cordis.patch.yml +457 -0
- package/deck_baseline.py +335 -0
- package/delivery.py +143 -0
- package/density-recipe.md +110 -0
- package/example.json +26 -0
- package/formula.py +125 -0
- package/gen_image.py +395 -0
- package/generate_consensus.py +892 -0
- package/generate_ppt.py +1356 -0
- package/grasp_evidence.py +150 -0
- package/image_check.py +148 -0
- package/image_fetch.py +253 -0
- package/ingest.py +93 -0
- package/media.py +470 -0
- package/package.json +68 -4
- package/preset.yml +2 -0
- package/quality_check.py +196 -0
- package/render_ppt.py +91 -0
- package/requirements.txt +7 -0
- package/restyle.py +80 -0
- package/run_pptfast.sh +27 -0
- package/run_py.sh +43 -0
- package/slide_ops.py +160 -0
- package/slide_patch.py +153 -0
- package/speaker_notes.py +245 -0
- package/style-recipes.md +166 -0
- package/video_gif.py +164 -0
- 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]))
|