chem-pdf2ppt 2.0.0

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.
@@ -0,0 +1,712 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 化学学术PPT创建脚本 - Chemistry Academic Presentation Builder
4
+
5
+ 支持实验化学、理论计算化学、实验+理论混合三种论文类型的学术PPT生成。
6
+ Chemistry Academic Presentation Builder for experimental, computational, and hybrid papers.
7
+ """
8
+ import sys
9
+ import os
10
+
11
+
12
+ def _safe_print(msg):
13
+ """Windows-safe print that avoids GBK encoding crashes."""
14
+ try:
15
+ print(msg)
16
+ except UnicodeEncodeError:
17
+ print(msg.encode('ascii', errors='replace').decode('ascii'))
18
+
19
+
20
+ from pptx import Presentation
21
+ from pptx.util import Inches, Pt, Emu, Cm
22
+ from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
23
+ from pptx.dml.color import RGBColor
24
+ from pptx.enum.shapes import MSO_SHAPE
25
+ import os.path
26
+
27
+
28
+ # ============================================================
29
+ # 配色主题 / Color Themes
30
+ # ============================================================
31
+
32
+ THEMES = {
33
+ "academic": {
34
+ "name": "学术经典",
35
+ "bg": (255, 255, 255), # 白
36
+ "title_color": (0, 51, 102), # 深蓝
37
+ "text_color": (51, 51, 51), # 深灰
38
+ "accent": (180, 30, 30), # 暗红
39
+ "light_bg": (240, 244, 248), # 浅蓝灰
40
+ "section_bg": (0, 51, 102), # 深蓝(章节页)
41
+ "section_text": (255, 255, 255), # 白
42
+ "muted": (140, 140, 140), # 灰
43
+ "border": (220, 220, 220), # 浅灰边框
44
+ "table_header": (0, 51, 102), # 深蓝
45
+ "table_stripe": (240, 244, 248), # 浅蓝灰
46
+ },
47
+ "molecular": {
48
+ "name": "分子科技",
49
+ "bg": (248, 249, 250),
50
+ "title_color": (26, 82, 118), # 钢蓝
51
+ "text_color": (44, 62, 80),
52
+ "accent": (231, 76, 60), # 亮红
53
+ "light_bg": (235, 240, 245),
54
+ "section_bg": (26, 82, 118),
55
+ "section_text": (255, 255, 255),
56
+ "muted": (149, 165, 166),
57
+ "border": (210, 218, 226),
58
+ "table_header": (26, 82, 118),
59
+ "table_stripe": (235, 240, 245),
60
+ },
61
+ "green": {
62
+ "name": "绿色化学",
63
+ "bg": (247, 249, 244),
64
+ "title_color": (30, 86, 49), # 深绿
65
+ "text_color": (51, 51, 51),
66
+ "accent": (212, 160, 23), # 金
67
+ "light_bg": (238, 243, 233),
68
+ "section_bg": (30, 86, 49),
69
+ "section_text": (255, 255, 255),
70
+ "muted": (150, 165, 140),
71
+ "border": (210, 220, 205),
72
+ "table_header": (30, 86, 49),
73
+ "table_stripe": (238, 243, 233),
74
+ },
75
+ "nature": {
76
+ "name": "Nature 风格",
77
+ "bg": (255, 255, 255),
78
+ "title_color": (34, 34, 34), # 近黑
79
+ "text_color": (68, 68, 68),
80
+ "accent": (0, 102, 204), # 蓝
81
+ "light_bg": (248, 248, 248),
82
+ "section_bg": (34, 34, 34),
83
+ "section_text": (255, 255, 255),
84
+ "muted": (160, 160, 160),
85
+ "border": (230, 230, 230),
86
+ "table_header": (34, 34, 34),
87
+ "table_stripe": (245, 245, 245),
88
+ },
89
+ }
90
+
91
+
92
+ class ChemistryPPT:
93
+ """化学学术PPT构建器 / Chemistry Academic PPT Builder"""
94
+
95
+ def __init__(self, theme="academic"):
96
+ if theme not in THEMES:
97
+ raise ValueError(f"Unknown theme: {theme}. Choose from: {list(THEMES.keys())}")
98
+
99
+ self.t = THEMES[theme]
100
+ self.prs = Presentation()
101
+ self.prs.slide_width = Inches(13.333)
102
+ self.prs.slide_height = Inches(7.5)
103
+ self.slide_count = 0
104
+ self._blank_layout = self.prs.slide_layouts[6] # blank layout
105
+ self._warnings = []
106
+ self._errors = []
107
+ self._missing_images = []
108
+ self._slide_types = []
109
+ self._theme_name = theme
110
+
111
+ # ============================================================
112
+ # 内部辅助 / Internal Helpers
113
+ # ============================================================
114
+
115
+ def _set_bg(self, slide, color):
116
+ bg = slide.background
117
+ fill = bg.fill
118
+ fill.solid()
119
+ fill.fore_color.rgb = RGBColor(*color)
120
+
121
+ def _add_textbox(self, slide, left, top, width, height,
122
+ text="", font_size=18, bold=False, color=None,
123
+ alignment=PP_ALIGN.LEFT, font_name=None, word_wrap=True):
124
+ """添加文本框并返回 text_frame"""
125
+ tb = slide.shapes.add_textbox(Inches(left), Inches(top),
126
+ Inches(width), Inches(height))
127
+ tf = tb.text_frame
128
+ tf.word_wrap = word_wrap
129
+ p = tf.paragraphs[0]
130
+ p.text = text
131
+ p.font.size = Pt(font_size)
132
+ p.font.bold = bold
133
+ p.font.color.rgb = RGBColor(*(color or self.t["text_color"]))
134
+ p.alignment = alignment
135
+ if font_name:
136
+ p.font.name = font_name
137
+ return tf
138
+
139
+ def _add_multi_paragraph(self, tf, texts, font_size=18, color=None,
140
+ bold=False, space_after=Pt(8)):
141
+ """在已有 text_frame 中添加多个段落"""
142
+ if color is None:
143
+ color = self.t["text_color"]
144
+ for i, text in enumerate(texts):
145
+ if i == 0:
146
+ p = tf.paragraphs[0]
147
+ else:
148
+ p = tf.add_paragraph()
149
+ p.text = text
150
+ p.font.size = Pt(font_size)
151
+ p.font.bold = bold
152
+ p.font.color.rgb = RGBColor(*color)
153
+ p.space_after = space_after
154
+ return tf
155
+
156
+ def _add_line(self, slide, x1, y1, x2, y2, color=None, width=Pt(1)):
157
+ """添加装饰线"""
158
+ connector = slide.shapes.add_connector(
159
+ 1, Inches(x1), Inches(y1), Inches(x2), Inches(y2))
160
+ connector.line.color.rgb = RGBColor(*(color or self.t["accent"]))
161
+ connector.line.width = width
162
+ return connector
163
+
164
+ def _add_page_number(self, slide, num):
165
+ """右下角页码"""
166
+ self._add_textbox(
167
+ slide, 12.0, 7.0, 1.2, 0.4,
168
+ str(num), font_size=10, color=self.t["muted"],
169
+ alignment=PP_ALIGN.RIGHT)
170
+
171
+ def _add_subtitle_line(self, slide, text, top=1.0):
172
+ """标题下方小字副标题"""
173
+ self._add_textbox(
174
+ slide, 0.7, top, 11.9, 0.4,
175
+ text, font_size=12, color=self.t["muted"],
176
+ alignment=PP_ALIGN.LEFT)
177
+
178
+ # ============================================================
179
+ # 幻灯片类型 / Slide Types
180
+ # ============================================================
181
+
182
+ def add_title_slide(self, title_cn, title_en="", authors="",
183
+ journal="", doi=""):
184
+ """封面页 / Title Slide"""
185
+ slide = self.prs.slides.add_slide(self._blank_layout)
186
+ self.slide_count += 1
187
+ self._slide_types.append("title")
188
+ self._set_bg(slide, self.t["bg"])
189
+
190
+ # 顶部装饰线
191
+ self._add_line(slide, 0.7, 1.5, 12.6, 1.5, self.t["accent"], Pt(3))
192
+
193
+ # 中文标题
194
+ self._add_textbox(
195
+ slide, 0.7, 1.8, 11.9, 2.0,
196
+ title_cn, font_size=38, bold=True,
197
+ color=self.t["title_color"], alignment=PP_ALIGN.LEFT)
198
+
199
+ # 英文标题
200
+ if title_en:
201
+ self._add_textbox(
202
+ slide, 0.7, 3.6, 11.9, 1.2,
203
+ title_en, font_size=20, bold=False,
204
+ color=self.t["muted"], alignment=PP_ALIGN.LEFT)
205
+
206
+ # 作者
207
+ if authors:
208
+ self._add_textbox(
209
+ slide, 0.7, 5.0, 11.9, 0.5,
210
+ authors, font_size=16, color=self.t["text_color"],
211
+ alignment=PP_ALIGN.LEFT)
212
+
213
+ # 期刊信息 + DOI
214
+ info_lines = []
215
+ if journal:
216
+ info_lines.append(journal)
217
+ if doi:
218
+ info_lines.append(f"DOI: {doi}")
219
+
220
+ if info_lines:
221
+ self._add_textbox(
222
+ slide, 0.7, 5.5, 11.9, 0.5,
223
+ " | ".join(info_lines), font_size=12,
224
+ color=self.t["muted"], alignment=PP_ALIGN.LEFT)
225
+
226
+ # 底部装饰条
227
+ self._add_line(slide, 0.7, 6.5, 12.6, 6.5, self.t["border"], Pt(0.5))
228
+
229
+ self._add_page_number(slide, self.slide_count)
230
+
231
+ def add_section_slide(self, title, subtitle=""):
232
+ """章节分隔页 / Section Divider"""
233
+ slide = self.prs.slides.add_slide(self._blank_layout)
234
+ self.slide_count += 1
235
+ self._slide_types.append("section")
236
+ self._set_bg(slide, self.t["section_bg"])
237
+
238
+ # 左装饰线
239
+ self._add_line(slide, 0.8, 2.6, 0.8, 5.0, self.t["accent"], Pt(3))
240
+
241
+ self._add_textbox(
242
+ slide, 1.5, 3.0, 10.5, 1.5,
243
+ title, font_size=40, bold=True,
244
+ color=self.t["section_text"], alignment=PP_ALIGN.LEFT)
245
+
246
+ if subtitle:
247
+ self._add_textbox(
248
+ slide, 1.5, 4.3, 10.5, 0.6,
249
+ subtitle, font_size=16,
250
+ color=tuple(min(c + 80, 255) for c in self.t["section_bg"]),
251
+ alignment=PP_ALIGN.LEFT)
252
+
253
+ self._add_page_number(slide, self.slide_count)
254
+
255
+ def add_content_slide(self, title, bullets, subtitle="", notes=""):
256
+ """文字要点页 / Content Slide"""
257
+ slide = self.prs.slides.add_slide(self._blank_layout)
258
+ self.slide_count += 1
259
+ self._slide_types.append("content")
260
+ self._set_bg(slide, self.t["bg"])
261
+
262
+ # 标题
263
+ self._add_textbox(
264
+ slide, 0.7, 0.4, 11.9, 0.8,
265
+ title, font_size=32, bold=True, color=self.t["title_color"])
266
+
267
+ # 标题下细线
268
+ self._add_line(slide, 0.7, 1.15, 8.0, 1.15, self.t["accent"], Pt(1.5))
269
+
270
+ if subtitle:
271
+ self._add_subtitle_line(slide, subtitle, top=1.25)
272
+
273
+ # 正文要点
274
+ top = 1.6 if not subtitle else 1.7
275
+ tb = slide.shapes.add_textbox(
276
+ Inches(0.7), Inches(top), Inches(11.6), Inches(5.3))
277
+ tf = tb.text_frame
278
+ tf.word_wrap = True
279
+
280
+ for i, bullet in enumerate(bullets):
281
+ if i == 0:
282
+ p = tf.paragraphs[0]
283
+ else:
284
+ p = tf.add_paragraph()
285
+ p.text = bullet
286
+ p.font.size = Pt(18)
287
+ p.font.color.rgb = RGBColor(*self.t["text_color"])
288
+ p.space_after = Pt(14)
289
+ p.level = 0
290
+
291
+ # 备注
292
+ if notes:
293
+ self._add_textbox(
294
+ slide, 0.7, 6.8, 11.9, 0.4,
295
+ f" {notes}", font_size=10, color=self.t["muted"])
296
+
297
+ self._add_page_number(slide, self.slide_count)
298
+ return slide
299
+
300
+ def add_figure_slide(self, title, figure_path, bullets=None,
301
+ figure_label="", caption="",
302
+ layout="figure_right", notes=""):
303
+ """图表+说明页 / Figure + Explanation Slide
304
+
305
+ Args:
306
+ layout: "figure_right" (图右文左), "figure_top" (图上文下),
307
+ "figure_full" (全图+底部说明), "figure_left" (图左文右)
308
+ """
309
+ slide = self.prs.slides.add_slide(self._blank_layout)
310
+ self.slide_count += 1
311
+ self._slide_types.append("figure")
312
+ self._set_bg(slide, self.t["bg"])
313
+
314
+ # 标题
315
+ self._add_textbox(
316
+ slide, 0.7, 0.3, 11.9, 0.7,
317
+ title, font_size=28, bold=True, color=self.t["title_color"])
318
+ self._add_line(slide, 0.7, 1.0, 7.0, 1.0, self.t["accent"], Pt(1.5))
319
+
320
+ bullets = bullets or []
321
+ img_ok = os.path.exists(figure_path) if figure_path else False
322
+ if figure_path and not img_ok:
323
+ self._missing_images.append(figure_path)
324
+
325
+ if layout == "figure_right":
326
+ fig_left = 7.2
327
+ fig_width = 5.5
328
+ text_width = 5.8
329
+
330
+ if bullets:
331
+ tb = slide.shapes.add_textbox(
332
+ Inches(0.7), Inches(1.3), Inches(text_width), Inches(5.3))
333
+ tf = tb.text_frame
334
+ tf.word_wrap = True
335
+ for i, b in enumerate(bullets):
336
+ if i == 0:
337
+ p = tf.paragraphs[0]
338
+ else:
339
+ p = tf.add_paragraph()
340
+ p.text = b
341
+ p.font.size = Pt(16)
342
+ p.font.color.rgb = RGBColor(*self.t["text_color"])
343
+ p.space_after = Pt(10)
344
+
345
+ if img_ok:
346
+ try:
347
+ slide.shapes.add_picture(
348
+ figure_path, Inches(fig_left), Inches(1.3),
349
+ width=Inches(fig_width))
350
+ except Exception as e:
351
+ self._errors.append(f"Failed to insert image '{figure_path}': {e}")
352
+ self._add_textbox(
353
+ slide, fig_left, 2.5, fig_width, 1.0,
354
+ f"[Image load error: {os.path.basename(figure_path)}]",
355
+ font_size=14, color=self.t["muted"],
356
+ alignment=PP_ALIGN.CENTER)
357
+ elif figure_path:
358
+ self._add_textbox(
359
+ slide, fig_left, 2.5, fig_width, 1.0,
360
+ f"[Figure not found: {os.path.basename(figure_path)}]",
361
+ font_size=14, color=self.t["muted"],
362
+ alignment=PP_ALIGN.CENTER)
363
+
364
+ elif layout == "figure_top":
365
+ if img_ok:
366
+ try:
367
+ slide.shapes.add_picture(
368
+ figure_path, Inches(0.7), Inches(1.2),
369
+ height=Inches(3.2))
370
+ except Exception as e:
371
+ self._errors.append(f"Failed to insert image '{figure_path}': {e}")
372
+
373
+ if bullets:
374
+ tb = slide.shapes.add_textbox(
375
+ Inches(0.7), Inches(4.6), Inches(11.6), Inches(2.5))
376
+ tf = tb.text_frame
377
+ tf.word_wrap = True
378
+ for i, b in enumerate(bullets):
379
+ if i == 0:
380
+ p = tf.paragraphs[0]
381
+ else:
382
+ p = tf.add_paragraph()
383
+ p.text = b
384
+ p.font.size = Pt(16)
385
+ p.font.color.rgb = RGBColor(*self.t["text_color"])
386
+ p.space_after = Pt(8)
387
+
388
+ elif layout == "figure_left":
389
+ if img_ok:
390
+ try:
391
+ slide.shapes.add_picture(
392
+ figure_path, Inches(0.5), Inches(1.3),
393
+ width=Inches(5.5))
394
+ except Exception as e:
395
+ self._errors.append(f"Failed to insert image '{figure_path}': {e}")
396
+
397
+ if bullets:
398
+ tb = slide.shapes.add_textbox(
399
+ Inches(6.5), Inches(1.3), Inches(6.2), Inches(5.3))
400
+ tf = tb.text_frame
401
+ tf.word_wrap = True
402
+ for i, b in enumerate(bullets):
403
+ if i == 0:
404
+ p = tf.paragraphs[0]
405
+ else:
406
+ p = tf.add_paragraph()
407
+ p.text = b
408
+ p.font.size = Pt(16)
409
+ p.font.color.rgb = RGBColor(*self.t["text_color"])
410
+ p.space_after = Pt(10)
411
+
412
+ elif layout == "figure_full":
413
+ if img_ok:
414
+ try:
415
+ slide.shapes.add_picture(
416
+ figure_path, Inches(0.5), Inches(1.2),
417
+ width=Inches(12.3))
418
+ except Exception as e:
419
+ self._errors.append(f"Failed to insert image '{figure_path}': {e}")
420
+
421
+ if bullets:
422
+ y = 6.0
423
+ for b in bullets:
424
+ self._add_textbox(
425
+ slide, 0.7, y, 11.9, 0.4,
426
+ b, font_size=12, color=self.t["text_color"])
427
+ y += 0.3
428
+
429
+ # 图注/来源
430
+ if figure_label or caption:
431
+ label_text = figure_label or ""
432
+ if caption:
433
+ label_text += f" | {caption}" if label_text else caption
434
+ self._add_textbox(
435
+ slide, 0.7, 6.8, 11.9, 0.4,
436
+ label_text, font_size=9, color=self.t["muted"])
437
+
438
+ if notes:
439
+ self._add_textbox(
440
+ slide, 0.7, 6.55, 11.9, 0.3,
441
+ f" {notes}", font_size=9, color=self.t["muted"])
442
+
443
+ self._add_page_number(slide, self.slide_count)
444
+ return slide
445
+
446
+ def add_table_slide(self, title, headers, rows, notes=""):
447
+ """数据表格页 / Table Slide"""
448
+ slide = self.prs.slides.add_slide(self._blank_layout)
449
+ self.slide_count += 1
450
+ self._slide_types.append("table")
451
+ self._set_bg(slide, self.t["bg"])
452
+
453
+ self._add_textbox(
454
+ slide, 0.7, 0.3, 11.9, 0.7,
455
+ title, font_size=28, bold=True, color=self.t["title_color"])
456
+ self._add_line(slide, 0.7, 1.0, 7.0, 1.0, self.t["accent"], Pt(1.5))
457
+
458
+ n_rows = len(rows) + 1 # +1 for header
459
+ n_cols = len(headers)
460
+
461
+ # 表格定位
462
+ table_left = Inches(0.7)
463
+ table_top = Inches(1.4)
464
+ table_width = Inches(12.0)
465
+ row_height = Inches(min(0.45, 4.5 / max(n_rows, 1)))
466
+ table_height = row_height * n_rows
467
+
468
+ table_shape = slide.shapes.add_table(
469
+ n_rows, n_cols, table_left, table_top,
470
+ table_width, table_height)
471
+ table = table_shape.table
472
+
473
+ # 设置列宽(等分)
474
+ col_width = int(Emu(table_width) / n_cols)
475
+ for col_idx in range(n_cols):
476
+ table.columns[col_idx].width = col_width
477
+
478
+ # 表头
479
+ for col_idx, header in enumerate(headers):
480
+ cell = table.cell(0, col_idx)
481
+ cell.text = header
482
+ for paragraph in cell.text_frame.paragraphs:
483
+ paragraph.font.size = Pt(14)
484
+ paragraph.font.bold = True
485
+ paragraph.font.color.rgb = RGBColor(255, 255, 255)
486
+ paragraph.alignment = PP_ALIGN.CENTER
487
+ # 表头背景
488
+ tcPr = cell._tc.get_or_add_tcPr()
489
+ solidFill = cell._tc.makeelement(
490
+ '{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill', {})
491
+ srgbClr = cell._tc.makeelement(
492
+ '{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr',
493
+ {'val': '{:02X}{:02X}{:02X}'.format(*self.t["table_header"])})
494
+ solidFill.append(srgbClr)
495
+ tcPr.append(solidFill)
496
+
497
+ # 数据行
498
+ for row_idx, row in enumerate(rows):
499
+ for col_idx, value in enumerate(row):
500
+ cell = table.cell(row_idx + 1, col_idx)
501
+ cell.text = str(value)
502
+ for paragraph in cell.text_frame.paragraphs:
503
+ paragraph.font.size = Pt(13)
504
+ paragraph.font.color.rgb = RGBColor(*self.t["text_color"])
505
+ paragraph.alignment = PP_ALIGN.CENTER
506
+ # 斑马纹
507
+ if row_idx % 2 == 0:
508
+ tcPr = cell._tc.get_or_add_tcPr()
509
+ solidFill = cell._tc.makeelement(
510
+ '{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill', {})
511
+ srgbClr = cell._tc.makeelement(
512
+ '{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr',
513
+ {'val': '{:02X}{:02X}{:02X}'.format(*self.t["table_stripe"])})
514
+ solidFill.append(srgbClr)
515
+ tcPr.append(solidFill)
516
+
517
+ if notes:
518
+ self._add_textbox(
519
+ slide, 0.7, 6.8, 11.9, 0.4,
520
+ f" {notes}", font_size=10, color=self.t["muted"])
521
+
522
+ self._add_page_number(slide, self.slide_count)
523
+ return slide
524
+
525
+ def add_summary_slide(self, title, bullets, notes=""):
526
+ """总结/结论页 / Summary Slide"""
527
+ slide = self.prs.slides.add_slide(self._blank_layout)
528
+ self.slide_count += 1
529
+ self._slide_types.append("summary")
530
+ self._set_bg(slide, self.t["light_bg"])
531
+
532
+ # 顶部色条
533
+ self._add_line(slide, 0, 0, 13.333, 0, self.t["title_color"], Pt(4))
534
+
535
+ self._add_textbox(
536
+ slide, 0.7, 0.6, 11.9, 0.8,
537
+ title, font_size=34, bold=True, color=self.t["title_color"])
538
+
539
+ # 要点
540
+ tb = slide.shapes.add_textbox(
541
+ Inches(0.7), Inches(1.7), Inches(11.6), Inches(5.0))
542
+ tf = tb.text_frame
543
+ tf.word_wrap = True
544
+
545
+ for i, bullet in enumerate(bullets):
546
+ if i == 0:
547
+ p = tf.paragraphs[0]
548
+ else:
549
+ p = tf.add_paragraph()
550
+ p.text = bullet
551
+ p.font.size = Pt(18)
552
+ p.font.color.rgb = RGBColor(*self.t["text_color"])
553
+ p.space_after = Pt(16)
554
+
555
+ if notes:
556
+ self._add_textbox(
557
+ slide, 0.7, 6.8, 11.9, 0.4,
558
+ f" {notes}", font_size=10, color=self.t["muted"])
559
+
560
+ self._add_page_number(slide, self.slide_count)
561
+ return slide
562
+
563
+ def add_thankyou_slide(self, title="谢谢!欢迎提问", subtitle="Thank you & Questions"):
564
+ """致谢页 / Thank You Slide"""
565
+ slide = self.prs.slides.add_slide(self._blank_layout)
566
+ self.slide_count += 1
567
+ self._slide_types.append("thankyou")
568
+ self._set_bg(slide, self.t["section_bg"])
569
+
570
+ self._add_textbox(
571
+ slide, 0, 2.5, 13.333, 1.5,
572
+ title, font_size=48, bold=True,
573
+ color=self.t["section_text"], alignment=PP_ALIGN.CENTER)
574
+
575
+ if subtitle:
576
+ self._add_textbox(
577
+ slide, 0, 4.2, 13.333, 0.8,
578
+ subtitle, font_size=20,
579
+ color=self.t["muted"], alignment=PP_ALIGN.CENTER)
580
+
581
+ self._add_page_number(slide, self.slide_count)
582
+ return slide
583
+
584
+ def add_image_grid_slide(self, title, image_items, cols=2, notes=""):
585
+ """多图网格页 / Multi-Image Grid Slide
586
+
587
+ Args:
588
+ image_items: list of (image_path, caption) tuples
589
+ cols: 每行图片数
590
+ """
591
+ slide = self.prs.slides.add_slide(self._blank_layout)
592
+ self.slide_count += 1
593
+ self._slide_types.append("image_grid")
594
+ self._set_bg(slide, self.t["bg"])
595
+
596
+ self._add_textbox(
597
+ slide, 0.7, 0.3, 11.9, 0.7,
598
+ title, font_size=28, bold=True, color=self.t["title_color"])
599
+ self._add_line(slide, 0.7, 1.0, 7.0, 1.0, self.t["accent"], Pt(1.5))
600
+
601
+ n_items = len(image_items)
602
+ rows = (n_items + cols - 1) // cols
603
+
604
+ cell_w = 11.8 / cols
605
+ cell_h = 5.2 / rows
606
+
607
+ for idx, (img_path, caption) in enumerate(image_items):
608
+ r = idx // cols
609
+ c = idx % cols
610
+ left = 0.7 + c * cell_w + 0.1
611
+ top = 1.3 + r * cell_h + 0.1
612
+
613
+ if os.path.exists(img_path):
614
+ try:
615
+ slide.shapes.add_picture(
616
+ img_path, Inches(left), Inches(top),
617
+ width=Inches(cell_w - 0.2), height=Inches(cell_h - 0.5))
618
+ except Exception:
619
+ pass
620
+
621
+ if caption:
622
+ self._add_textbox(
623
+ slide, left, top + cell_h - 0.5, cell_w - 0.2, 0.4,
624
+ caption, font_size=9, color=self.t["muted"],
625
+ alignment=PP_ALIGN.CENTER)
626
+
627
+ if notes:
628
+ self._add_textbox(
629
+ slide, 0.7, 6.8, 11.9, 0.4,
630
+ f" {notes}", font_size=10, color=self.t["muted"])
631
+
632
+ self._add_page_number(slide, self.slide_count)
633
+ return slide
634
+
635
+ # ============================================================
636
+ # 保存 / Save
637
+ # ============================================================
638
+
639
+ def save(self, output_path):
640
+ """保存 PPTX 文件"""
641
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
642
+ self.prs.save(output_path)
643
+ _safe_print(f"[ChemistryPPT] Saved: {output_path}")
644
+ _safe_print(f"[ChemistryPPT] Slides: {self.slide_count} | Theme: {self.t['name']}")
645
+
646
+ type_counts = {}
647
+ for st in self._slide_types:
648
+ type_counts[st] = type_counts.get(st, 0) + 1
649
+ type_summary = ", ".join(f"{v}x {k}" for k, v in type_counts.items())
650
+ _safe_print(f"[ChemistryPPT] Types: {type_summary}")
651
+
652
+ if self._missing_images:
653
+ _safe_print(f"[ChemistryPPT] Missing images: {len(self._missing_images)}")
654
+ for m in self._missing_images:
655
+ print(f" - {m}")
656
+ if self._warnings:
657
+ _safe_print(f"[ChemistryPPT] Warnings: {len(self._warnings)}")
658
+ for w in self._warnings:
659
+ print(f" - {w}")
660
+ if self._errors:
661
+ _safe_print(f"[ChemistryPPT] Errors: {len(self._errors)}")
662
+ for e in self._errors:
663
+ print(f" - {e}")
664
+
665
+ return output_path
666
+
667
+ def get_report(self):
668
+ """获取构建报告"""
669
+ return {
670
+ "theme": self._theme_name,
671
+ "theme_display": self.t["name"],
672
+ "total_slides": self.slide_count,
673
+ "slide_types": self._slide_types,
674
+ "missing_images": self._missing_images,
675
+ "warnings": self._warnings,
676
+ "errors": self._errors,
677
+ }
678
+
679
+ def save_report(self, output_path):
680
+ """保存 JSON 构建报告"""
681
+ import json
682
+ report = self.get_report()
683
+ report_path = output_path.replace('.pptx', '_report.json')
684
+ with open(report_path, 'w', encoding='utf-8') as f:
685
+ json.dump(report, f, indent=2, ensure_ascii=False)
686
+ _safe_print(f"[ChemistryPPT] Report saved: {report_path}")
687
+ return report_path
688
+
689
+
690
+ # ============================================================
691
+ # 命令行入口 / CLI Entry
692
+ # ============================================================
693
+
694
+ def main():
695
+ print("=" * 50)
696
+ print("Chemistry Academic PPT Builder")
697
+ print("=" * 50)
698
+ print()
699
+ print("Usage (Python API):")
700
+ print(" from create_ppt import ChemistryPPT")
701
+ print(" ppt = ChemistryPPT(theme='academic')")
702
+ print(" ppt.add_title_slide(...)")
703
+ print(" ppt.save('output.pptx')")
704
+ print()
705
+ print("Available themes:", ", ".join(THEMES.keys()))
706
+ print()
707
+ print("For automated paper-to-PPT conversion, use this skill's")
708
+ print("SKILL.md workflow: read paper → classify → build slides.")
709
+
710
+
711
+ if __name__ == "__main__":
712
+ main()